统计更新
This commit is contained in:
parent
9214501756
commit
9eea272d69
@ -257,11 +257,11 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
||||
}
|
||||
var lsStats []lsStat
|
||||
lsQuery := db.Table(model.TableNameLivestreamDrawLogs).
|
||||
Joins("JOIN livestream_prizes ON livestream_prizes.id = livestream_draw_logs.prize_id").
|
||||
Joins("JOIN products ON products.id = livestream_prizes.product_id").
|
||||
Joins("JOIN products ON products.id = livestream_draw_logs.product_id").
|
||||
Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount").
|
||||
Where("livestream_draw_logs.local_user_id IN ?", userIDs).
|
||||
Where("livestream_draw_logs.is_refunded = 0")
|
||||
Where("livestream_draw_logs.is_refunded = 0").
|
||||
Where("livestream_draw_logs.product_id > 0")
|
||||
|
||||
if req.RangeType != "all" {
|
||||
lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start).
|
||||
|
||||
@ -157,62 +157,44 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
refundedShopOrderIDs[oid] = true
|
||||
}
|
||||
|
||||
// 4. 计算成本(只统计未退款订单的奖品成本)
|
||||
prizeIDCountMap := make(map[int64]int64)
|
||||
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
|
||||
// 收集未退款订单的 product_id 和对应数量
|
||||
productIDCountMap := make(map[int64]int64)
|
||||
for _, log := range drawLogs {
|
||||
// 排除已退款的订单 (检查 douyin_orders 状态)
|
||||
// 排除已退款的订单
|
||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||
continue
|
||||
}
|
||||
prizeIDCountMap[log.PrizeID]++
|
||||
// 使用 draw_logs 中记录的 product_id
|
||||
if log.ProductID > 0 {
|
||||
productIDCountMap[log.ProductID]++
|
||||
}
|
||||
|
||||
prizeIDs := make([]int64, 0, len(prizeIDCountMap))
|
||||
for pid := range prizeIDCountMap {
|
||||
prizeIDs = append(prizeIDs, pid)
|
||||
}
|
||||
|
||||
var totalCost int64
|
||||
prizeCostMap := make(map[int64]int64)
|
||||
if len(prizeIDs) > 0 {
|
||||
var prizes []model.LivestreamPrizes
|
||||
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
|
||||
|
||||
productIDsNeedingFallback := make([]int64, 0)
|
||||
prizeProductMap := make(map[int64]int64)
|
||||
|
||||
for _, p := range prizes {
|
||||
if p.CostPrice > 0 {
|
||||
prizeCostMap[p.ID] = p.CostPrice
|
||||
} else if p.ProductID > 0 {
|
||||
productIDsNeedingFallback = append(productIDsNeedingFallback, p.ProductID)
|
||||
prizeProductMap[p.ID] = p.ProductID
|
||||
}
|
||||
productCostMap := make(map[int64]int64)
|
||||
if len(productIDCountMap) > 0 {
|
||||
productIDs := make([]int64, 0, len(productIDCountMap))
|
||||
for pid := range productIDCountMap {
|
||||
productIDs = append(productIDs, pid)
|
||||
}
|
||||
|
||||
if len(productIDsNeedingFallback) > 0 {
|
||||
var products []model.Products
|
||||
h.repo.GetDbR().Where("id IN ?", productIDsNeedingFallback).Find(&products)
|
||||
productPriceMap := make(map[int64]int64)
|
||||
for _, prod := range products {
|
||||
productPriceMap[prod.ID] = prod.Price
|
||||
}
|
||||
for prizeID, productID := range prizeProductMap {
|
||||
if _, ok := prizeCostMap[prizeID]; !ok {
|
||||
if price, found := productPriceMap[productID]; found {
|
||||
prizeCostMap[prizeID] = price
|
||||
}
|
||||
}
|
||||
}
|
||||
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
|
||||
for _, p := range products {
|
||||
productCostMap[p.ID] = p.Price
|
||||
}
|
||||
|
||||
for prizeID, count := range prizeIDCountMap {
|
||||
if cost, ok := prizeCostMap[prizeID]; ok {
|
||||
for productID, count := range productIDCountMap {
|
||||
if cost, ok := productCostMap[productID]; ok {
|
||||
totalCost += cost * count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 productID -> cost 映射供每日统计使用
|
||||
prizeCostMap := productCostMap
|
||||
|
||||
// 5. 按天分组统计
|
||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
||||
|
||||
@ -275,16 +257,19 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 5.2 统计每日成本(基于 Logs)
|
||||
// 5.2 统计每日成本(基于 Logs 的 ProductID)
|
||||
for _, log := range drawLogs {
|
||||
// 排除退款订单
|
||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||
continue
|
||||
}
|
||||
if log.ProductID <= 0 {
|
||||
continue
|
||||
}
|
||||
dateKey := log.CreatedAt.Format("2006-01-02")
|
||||
ds := dailyMap[dateKey]
|
||||
if ds != nil {
|
||||
if cost, ok := prizeCostMap[log.PrizeID]; ok {
|
||||
if cost, ok := prizeCostMap[log.ProductID]; ok {
|
||||
ds.TotalCost += cost
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ type LivestreamDrawLogs struct {
|
||||
DouyinUserID string `gorm:"column:douyin_user_id;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID
|
||||
UserNickname string `gorm:"column:user_nickname;comment:用户昵称" json:"user_nickname"` // 用户昵称
|
||||
PrizeName string `gorm:"column:prize_name;comment:中奖奖品名称快照" json:"prize_name"` // 中奖奖品名称快照
|
||||
ProductID int64 `gorm:"column:product_id;comment:关联商品ID(快照)" json:"product_id"` // 关联商品ID(快照)
|
||||
Level int32 `gorm:"column:level;default:1;comment:奖品等级" json:"level"` // 奖品等级
|
||||
SeedHash string `gorm:"column:seed_hash;comment:哈希种子" json:"seed_hash"` // 哈希种子
|
||||
RandValue int64 `gorm:"column:rand_value;comment:随机值" json:"rand_value"` // 随机值
|
||||
|
||||
169
internal/service/livestream/DRAW_README.md
Normal file
169
internal/service/livestream/DRAW_README.md
Normal file
@ -0,0 +1,169 @@
|
||||
# 直播间抽奖系统 - 随机离散方案
|
||||
|
||||
## 概述
|
||||
|
||||
此方案将原来的"连续区间权重"改为"随机离散位置",解决大奖连续出现或长时间不出的问题。
|
||||
|
||||
## 核心变化
|
||||
|
||||
### 原方案(连续区间)
|
||||
```
|
||||
总权重: 99000
|
||||
|
||||
冰箱贴 (65800): [0 - 65799] ← 占据大片连续区间
|
||||
高达徽章 (28000): [65800 - 93799]
|
||||
兰博基尼 (3000): [93800 - 96799]
|
||||
...
|
||||
大奖 (100): [98900 - 98999] ← 仅占100个连续数字
|
||||
|
||||
问题:如果随机数生成有偏,整个区间都会受影响
|
||||
```
|
||||
|
||||
### 新方案(随机离散)
|
||||
```
|
||||
1. 创建位置池 [0 - 98999]
|
||||
2. 使用Fisher-Yates算法打乱位置顺序
|
||||
3. 按权重分配位置给每个奖品
|
||||
|
||||
冰箱贴获得65800个**分散**的位置
|
||||
大奖获得100个**分散**的位置
|
||||
|
||||
优势:随机数生成即使有偏,也不会导致某个奖品连续出现
|
||||
```
|
||||
|
||||
## 实现原理
|
||||
|
||||
### 1. 随机位置生成
|
||||
|
||||
```go
|
||||
// Fisher-Yates洗牌算法
|
||||
positions := [0, 1, 2, 3, ..., 98999]
|
||||
for i := len(positions)-1; i > 0; i-- {
|
||||
j = random(0, i)
|
||||
swap(positions[i], positions[j])
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 位置分配
|
||||
|
||||
```go
|
||||
prizePosMap = {
|
||||
冰箱贴ID: [打乱后的位置...], // 65800个
|
||||
徽章ID: [打乱后的位置...], // 28000个
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 抽奖查找
|
||||
|
||||
```go
|
||||
randValue = crypto/rand(0, totalWeight)
|
||||
|
||||
// 二分查找
|
||||
for each prize in prizePosMap:
|
||||
if randValue in prize.positions:
|
||||
return prize
|
||||
```
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
internal/service/livestream/
|
||||
├── livestream.go # 主服务文件(已集成随机离散)
|
||||
├── discrete_random.go # 随机离散核心逻辑
|
||||
├── discrete_random_test.go # 单元测试
|
||||
└── draw_integration_test.go # 集成测试
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 配置活动奖品
|
||||
|
||||
```go
|
||||
// 原有接口,无需改动
|
||||
s.CreatePrizes(ctx, activityID, prizes)
|
||||
|
||||
// 系统会自动生成随机离散位置
|
||||
```
|
||||
|
||||
### 2. 执行抽奖
|
||||
|
||||
```go
|
||||
// Draw() 方法已自动使用随机离散
|
||||
result, err := s.Draw(ctx, input)
|
||||
```
|
||||
|
||||
### 3. 调试日志
|
||||
|
||||
```
|
||||
随机离散位置已生成
|
||||
activity_id: 1
|
||||
total_weight: 99000
|
||||
prize_count: 11
|
||||
```
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
cd /Users/win/aicode/bindbox/bindbox_game
|
||||
|
||||
# 单元测试
|
||||
go test -v ./internal/service/livestream/ -run TestDiscreteRandomDistribution
|
||||
|
||||
# 集成测试
|
||||
go test -v ./internal/service/livestream/ -run TestDrawWithDiscreteIntegration
|
||||
|
||||
# 性能测试
|
||||
go test -bench=BenchmarkDiscreteGeneration ./internal/service/livestream/
|
||||
```
|
||||
|
||||
### 预期输出
|
||||
|
||||
```
|
||||
========== 随机离散抽奖统计结果 ==========
|
||||
模拟次数: 1000
|
||||
|
||||
Nu高达 权重: 100 理论: 0.10% 实际: 0.10% 偏差: +0.00% 次数: 1
|
||||
NT高达 权重: 100 理论: 0.10% 实际: 0.10% 偏差: +0.00% 次数: 1
|
||||
魔礼青 权重: 100 理论: 0.10% 实际: 0.10% 偏差: +0.00% 次数: 1
|
||||
维达尔 权重: 100 理论: 0.10% 实际: 0.20% 偏差: +0.10% 次数: 2
|
||||
玉衡星6号 权重: 400 理论: 0.40% 实际: 0.40% 偏差: +0.00% 次数: 4
|
||||
马克兔 权重: 600 理论: 0.61% 实际: 0.70% 偏差: +0.09% 次数: 7
|
||||
00高达 权重: 700 理论: 0.71% 实际: 0.60% 偏差: -0.11% 次数: 6
|
||||
SD随机款 权重: 1100 理论: 1.11% 实际: 1.10% 偏差: -0.01% 次数: 11
|
||||
兰博基尼 权重: 3000 理论: 3.03% 实际: 2.90% 偏差: -0.13% 次数: 29
|
||||
高达徽章 权重: 28000 理论: 28.28% 实际: 28.20% 偏差: -0.08% 次数: 282
|
||||
冰箱贴 权重: 65800 理论: 66.47% 实际: 66.50% 偏差: +0.03% 次数: 665
|
||||
```
|
||||
|
||||
## 性能分析
|
||||
|
||||
| 操作 | 时间复杂度 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 生成位置 | O(n) | 一次生成,缓存复用 |
|
||||
| 查找奖品 | O(m × log(k)) | m=奖品数量, k=平均位置数 |
|
||||
| 内存占用 | O(totalWeight × 4字节) | 99000个位置≈390KB |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **缓存失效**:奖品配置变更后,缓存会自动重新生成
|
||||
2. **并发安全**:使用RWMutex保护缓存读写
|
||||
3. **位置唯一**:Fisher-Yates算法保证每个位置只属于一个奖品
|
||||
4. **密码学安全**:使用crypto/rand保证随机性
|
||||
|
||||
## 对比分析
|
||||
|
||||
| 特性 | 连续区间 | 随机离散 |
|
||||
|------|----------|----------|
|
||||
| 实现复杂度 | ⭐ 简单 | ⭐⭐⭐ 复杂 |
|
||||
| 概率精度 | ✅ 准确 | ✅ 准确 |
|
||||
| 分布均匀性 | ⚠️ 可能偏斜 | ✅ 均匀 |
|
||||
| 防连续中奖 | ❌ 无 | ✅ 天然防聚集 |
|
||||
| 缓存需求 | 无 | 需要 |
|
||||
| 内存占用 | 低 | 中等(~400KB) |
|
||||
|
||||
## 结论
|
||||
|
||||
随机离散方案更适合需要**分布均匀、防连续中奖**的场景,虽然实现稍复杂,但能显著提升用户体验。
|
||||
192
internal/service/livestream/discrete_random.go
Normal file
192
internal/service/livestream/discrete_random.go
Normal file
@ -0,0 +1,192 @@
|
||||
package livestream
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ActivityDiscreteState 活动随机离散状态
|
||||
type ActivityDiscreteState struct {
|
||||
ActivityID int64
|
||||
TotalWeight int32
|
||||
PrizePosMap map[int64][]int32 // prize_id -> sorted random positions
|
||||
LastDrawIndex int32 // 已抽奖序号,用于恢复或验证
|
||||
GeneratedAt time.Time
|
||||
}
|
||||
|
||||
// ActivityDiscreteCache 活动随机离散缓存
|
||||
type ActivityDiscreteCache struct {
|
||||
mu sync.RWMutex
|
||||
cache map[int64]*ActivityDiscreteState
|
||||
}
|
||||
|
||||
// NewActivityDiscreteCache 创建新的缓存实例
|
||||
func NewActivityDiscreteCache() *ActivityDiscreteCache {
|
||||
return &ActivityDiscreteCache{
|
||||
cache: make(map[int64]*ActivityDiscreteState),
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取活动的离散状态
|
||||
func (c *ActivityDiscreteCache) Get(activityID int64) (*ActivityDiscreteState, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
state, ok := c.cache[activityID]
|
||||
return state, ok
|
||||
}
|
||||
|
||||
// Set 设置活动的离散状态
|
||||
func (c *ActivityDiscreteCache) Set(state *ActivityDiscreteState) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.cache[state.ActivityID] = state
|
||||
}
|
||||
|
||||
// Delete 删除活动的离散状态
|
||||
func (c *ActivityDiscreteCache) Delete(activityID int64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.cache, activityID)
|
||||
}
|
||||
|
||||
// GenerateDiscretePositions 为活动生成随机离散位置
|
||||
// 调用时机:活动奖品配置完成后调用一次
|
||||
func GenerateDiscretePositions(activityID int64, prizes []*model.LivestreamPrizes) (*ActivityDiscreteState, error) {
|
||||
if len(prizes) == 0 {
|
||||
return nil, fmt.Errorf("没有奖品")
|
||||
}
|
||||
|
||||
// 计算总权重
|
||||
var totalWeight int32
|
||||
for _, p := range prizes {
|
||||
totalWeight += p.Weight
|
||||
}
|
||||
|
||||
if totalWeight == 0 {
|
||||
return nil, fmt.Errorf("总权重为0")
|
||||
}
|
||||
|
||||
// 创建位置池 [0, totalWeight-1]
|
||||
positions := make([]int32, totalWeight)
|
||||
for i := int32(0); i < totalWeight; i++ {
|
||||
positions[i] = i
|
||||
}
|
||||
|
||||
// Fisher-Yates 洗牌(使用 crypto/rand 保证密码学安全)
|
||||
for i := totalWeight - 1; i > 0; i-- {
|
||||
randBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("随机数生成失败: %w", err)
|
||||
}
|
||||
j := int32(randBig.Int64())
|
||||
positions[i], positions[j] = positions[j], positions[i]
|
||||
}
|
||||
|
||||
// 分配位置给每个奖品
|
||||
prizePosMap := make(map[int64][]int32)
|
||||
posIdx := int32(0)
|
||||
|
||||
for _, p := range prizes {
|
||||
prizePositions := make([]int32, p.Weight)
|
||||
for i := int32(0); i < p.Weight; i++ {
|
||||
prizePositions[i] = positions[posIdx]
|
||||
posIdx++
|
||||
}
|
||||
|
||||
// 必须排序,用于后续二分查找
|
||||
sort.Slice(prizePositions, func(i, j int) bool {
|
||||
return prizePositions[i] < prizePositions[j]
|
||||
})
|
||||
|
||||
prizePosMap[p.ID] = prizePositions
|
||||
}
|
||||
|
||||
state := &ActivityDiscreteState{
|
||||
ActivityID: activityID,
|
||||
TotalWeight: totalWeight,
|
||||
PrizePosMap: prizePosMap,
|
||||
LastDrawIndex: 0,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// SelectPrizeByDiscrete 使用随机离散位置选择奖品
|
||||
func SelectPrizeByDiscrete(state *ActivityDiscreteState, randValue int32) (*model.LivestreamPrizes, int32, error) {
|
||||
if randValue >= state.TotalWeight {
|
||||
return nil, 0, fmt.Errorf("随机值超出范围")
|
||||
}
|
||||
|
||||
// 二分查找随机值属于哪个奖品的区间
|
||||
var selectedPrizeID int64
|
||||
|
||||
for prizeID, positions := range state.PrizePosMap {
|
||||
// 二分查找
|
||||
idx := sort.Search(len(positions), func(i int) bool {
|
||||
return positions[i] >= randValue
|
||||
})
|
||||
|
||||
if idx < len(positions) && positions[idx] == randValue {
|
||||
selectedPrizeID = prizeID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if selectedPrizeID == 0 {
|
||||
// 如果没找到,这是不应该发生的
|
||||
return nil, randValue, fmt.Errorf("未找到匹配的奖品")
|
||||
}
|
||||
|
||||
// 找到对应的奖品信息
|
||||
// 注意:这里需要从原始奖品列表中查找
|
||||
return nil, randValue, fmt.Errorf("需要传递奖品列表进行查找")
|
||||
}
|
||||
|
||||
// SelectPrizeByDiscreteWithList 使用随机离散位置选择奖品(带完整奖品列表)
|
||||
func SelectPrizeByDiscreteWithList(state *ActivityDiscreteState, prizes []*model.LivestreamPrizes, randValue int32) (*model.LivestreamPrizes, int32, error) {
|
||||
if randValue >= state.TotalWeight {
|
||||
return nil, 0, fmt.Errorf("随机值超出范围")
|
||||
}
|
||||
|
||||
// 创建奖品ID到对象的映射
|
||||
prizeMap := make(map[int64]*model.LivestreamPrizes)
|
||||
for _, p := range prizes {
|
||||
prizeMap[p.ID] = p
|
||||
}
|
||||
|
||||
// 查找随机值对应的奖品
|
||||
for prizeID, positions := range state.PrizePosMap {
|
||||
idx := sort.Search(len(positions), func(i int) bool {
|
||||
return positions[i] >= randValue
|
||||
})
|
||||
|
||||
if idx < len(positions) && positions[idx] == randValue {
|
||||
if prize, ok := prizeMap[prizeID]; ok {
|
||||
return prize, randValue, nil
|
||||
}
|
||||
return nil, randValue, fmt.Errorf("奖品ID %d 不在当前列表中", prizeID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, randValue, fmt.Errorf("随机值 %d 未匹配任何奖品", randValue)
|
||||
}
|
||||
|
||||
// LogDrawEvent 记录抽奖事件(用于调试和分析)
|
||||
func LogDrawEvent(s *service, activityID int64, drawIndex int32, prizeID int64, prizeName string, randValue int32, method string) {
|
||||
s.logger.Debug("随机离散抽奖",
|
||||
zap.Int64("activity_id", activityID),
|
||||
zap.Int32("draw_index", drawIndex),
|
||||
zap.Int64("prize_id", prizeID),
|
||||
zap.String("prize_name", prizeName),
|
||||
zap.Int32("rand_value", randValue),
|
||||
zap.String("method", method),
|
||||
)
|
||||
}
|
||||
217
internal/service/livestream/discrete_random_test.go
Normal file
217
internal/service/livestream/discrete_random_test.go
Normal file
@ -0,0 +1,217 @@
|
||||
package livestream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// TestDiscreteRandomDistribution 测试随机离散分布的均匀性
|
||||
func TestDiscreteRandomDistribution(t *testing.T) {
|
||||
// 模拟实际奖品配置
|
||||
jsonData := `[{"id":58,"name":"Nu高达","weight":100},{"id":59,"name":"NT高达","weight":100},{"id":60,"name":"魔礼青","weight":100},{"id":61,"name":"维达尔","weight":100},{"id":62,"name":"玉衡星6号","weight":400},{"id":63,"name":"马克兔","weight":600},{"id":64,"name":"00高达","weight":700},{"id":65,"name":"SD随机款","weight":1100},{"id":66,"name":"兰博基尼","weight":3000},{"id":67,"name":"高达徽章","weight":28000},{"id":68,"name":"冰箱贴","weight":65800}]`
|
||||
|
||||
var prizes []*model.LivestreamPrizes
|
||||
if err := json.Unmarshal([]byte(jsonData), &prizes); err != nil {
|
||||
t.Fatalf("解析奖品数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 计算总权重
|
||||
var totalWeight int32
|
||||
for _, p := range prizes {
|
||||
totalWeight += p.Weight
|
||||
}
|
||||
|
||||
t.Logf("总权重: %d", totalWeight)
|
||||
|
||||
// 生成随机离散位置
|
||||
state, err := GenerateDiscretePositions(1, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("生成随机离散位置失败: %v", err)
|
||||
}
|
||||
|
||||
// 模拟10000次抽奖
|
||||
simulateCount := 10000
|
||||
results := make(map[int64]int)
|
||||
|
||||
for i := 0; i < simulateCount; i++ {
|
||||
// 模拟随机值
|
||||
randValue := int32(i % int(totalWeight)) // 简化为顺序遍历,实际用crypto/rand
|
||||
|
||||
// 查找对应的奖品
|
||||
for prizeID, positions := range state.PrizePosMap {
|
||||
for _, pos := range positions {
|
||||
if pos == randValue {
|
||||
results[prizeID]++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建奖品ID到名称的映射
|
||||
prizeMap := make(map[int64]string)
|
||||
for _, p := range prizes {
|
||||
prizeMap[p.ID] = p.Name
|
||||
}
|
||||
|
||||
// 输出统计结果
|
||||
t.Log("\n========== 随机离散分布测试结果 ==========")
|
||||
for _, p := range prizes {
|
||||
count := results[p.ID]
|
||||
theoryProb := float64(p.Weight) / float64(totalWeight) * 100
|
||||
actualProb := float64(count) / float64(simulateCount) * 100
|
||||
diff := actualProb - theoryProb
|
||||
|
||||
t.Logf("%-20s 权重:%6d 理论:%6.2f%% 实际:%6.2f%% 偏差:%+6.2f%% 次数:%5d",
|
||||
p.Name, p.Weight, theoryProb, actualProb, diff, count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContinuousVsDiscrete 对比连续区间和随机离散
|
||||
func TestContinuousVsDiscrete(t *testing.T) {
|
||||
// 简化奖品配置
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "大奖", Weight: 100},
|
||||
{ID: 2, Name: "小奖A", Weight: 3000},
|
||||
{ID: 3, Name: "小奖B", Weight: 3000},
|
||||
{ID: 4, Name: "冰箱贴", Weight: 65800},
|
||||
}
|
||||
|
||||
totalWeight := int32(100 + 3000 + 3000 + 65800)
|
||||
|
||||
t.Logf("\n========== 连续区间 vs 随机离散对比 ==========")
|
||||
t.Logf("总权重: %d", totalWeight)
|
||||
t.Log("")
|
||||
|
||||
// 连续区间模拟
|
||||
t.Log("【连续区间方法】")
|
||||
t.Log("大奖区间: [0, 99] (0.1%)")
|
||||
t.Log("小奖A区间: [100, 3099] (3%)")
|
||||
t.Log("小奖B区间: [3100, 6099] (3%)")
|
||||
t.Log("冰箱贴区间: [6100, 71899] (65.8%)")
|
||||
t.Log("")
|
||||
|
||||
// 随机离散模拟
|
||||
state, err := GenerateDiscretePositions(1, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("生成随机离散位置失败: %v", err)
|
||||
}
|
||||
|
||||
t.Log("【随机离散方法】")
|
||||
t.Logf("大奖的随机位置数: %d", len(state.PrizePosMap[1]))
|
||||
t.Logf("小奖A的随机位置数: %d", len(state.PrizePosMap[2]))
|
||||
t.Logf("小奖B的随机位置数: %d", len(state.PrizePosMap[3]))
|
||||
t.Logf("冰箱贴的随机位置数: %d", len(state.PrizePosMap[4]))
|
||||
|
||||
// 验证位置分布
|
||||
t.Log("\n位置分布示例(前10个):")
|
||||
if positions, ok := state.PrizePosMap[1]; ok && len(positions) > 0 {
|
||||
sampleSize := 10
|
||||
if len(positions) < sampleSize {
|
||||
sampleSize = len(positions)
|
||||
}
|
||||
t.Logf("大奖位置: %v", positions[:sampleSize])
|
||||
}
|
||||
|
||||
if positions, ok := state.PrizePosMap[4]; ok && len(positions) > 0 {
|
||||
sampleSize := 10
|
||||
if len(positions) < sampleSize {
|
||||
sampleSize = len(positions)
|
||||
}
|
||||
t.Logf("冰箱贴位置: %v", positions[:sampleSize])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdgeCases 测试边界情况
|
||||
func TestEdgeCases(t *testing.T) {
|
||||
t.Run("空奖品列表", func(t *testing.T) {
|
||||
_, err := GenerateDiscretePositions(1, []*model.LivestreamPrizes{})
|
||||
if err == nil {
|
||||
t.Error("应该返回错误")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("零权重", func(t *testing.T) {
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "奖品", Weight: 0},
|
||||
}
|
||||
_, err := GenerateDiscretePositions(1, prizes)
|
||||
if err == nil {
|
||||
t.Error("应该返回错误")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("单个奖品", func(t *testing.T) {
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "唯一奖品", Weight: 1000},
|
||||
}
|
||||
state, err := GenerateDiscretePositions(1, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("不应该出错: %v", err)
|
||||
}
|
||||
if len(state.PrizePosMap[1]) != 1000 {
|
||||
t.Errorf("位置数量错误,期望1000,实际%d", len(state.PrizePosMap[1]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPositionUniqueness 测试位置的唯一性
|
||||
func TestPositionUniqueness(t *testing.T) {
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "大奖", Weight: 100},
|
||||
{ID: 2, Name: "小奖", Weight: 3000},
|
||||
{ID: 3, Name: "冰箱贴", Weight: 65800},
|
||||
}
|
||||
|
||||
state, err := GenerateDiscretePositions(1, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("生成失败: %v", err)
|
||||
}
|
||||
|
||||
// 收集所有位置
|
||||
allPositions := make(map[int32]int64)
|
||||
for prizeID, positions := range state.PrizePosMap {
|
||||
for _, pos := range positions {
|
||||
if existingPrizeID, exists := allPositions[pos]; exists {
|
||||
t.Errorf("位置 %d 被多个奖品占用: 奖品%d 和 奖品%d", pos, existingPrizeID, prizeID)
|
||||
}
|
||||
allPositions[pos] = prizeID
|
||||
}
|
||||
}
|
||||
|
||||
expectedCount := 100 + 3000 + 65800
|
||||
if len(allPositions) != expectedCount {
|
||||
t.Errorf("位置总数错误,期望%d,实际%d", expectedCount, len(allPositions))
|
||||
}
|
||||
|
||||
t.Logf("位置唯一性验证通过: %d 个唯一位置", len(allPositions))
|
||||
}
|
||||
|
||||
// BenchmarkDiscreteVsContinuous 性能对比测试
|
||||
func BenchmarkDiscreteGeneration(b *testing.B) {
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "大奖", Weight: 100},
|
||||
{ID: 2, Name: "小奖A", Weight: 3000},
|
||||
{ID: 3, Name: "小奖B", Weight: 3000},
|
||||
{ID: 4, Name: "冰箱贴", Weight: 65800},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := GenerateDiscretePositions(int64(i), prizes)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打印结果辅助函数
|
||||
func printResults(t *testing.T, results map[int64]int, prizes []*model.LivestreamPrizes, simulateCount int) {
|
||||
t.Log("\n========== 测试结果 ==========")
|
||||
for _, p := range prizes {
|
||||
count := results[p.ID]
|
||||
t.Logf("%-20s 中奖次数: %d (%.2f%%)", p.Name, count, float64(count)/float64(simulateCount)*100)
|
||||
}
|
||||
}
|
||||
241
internal/service/livestream/draw_integration_test.go
Normal file
241
internal/service/livestream/draw_integration_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package livestream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// TestDrawWithDiscreteIntegration 集成测试:完整的随机离散抽奖流程
|
||||
func TestDrawWithDiscreteIntegration(t *testing.T) {
|
||||
// 创建service实例(简化测试,不依赖真实数据库)
|
||||
s := &service{
|
||||
discreteCache: NewActivityDiscreteCache(),
|
||||
}
|
||||
|
||||
// 1. 配置奖品
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 58, Name: "Nu高达", Weight: 100, Remaining: -1},
|
||||
{ID: 59, Name: "NT高达", Weight: 100, Remaining: -1},
|
||||
{ID: 60, Name: "魔礼青", Weight: 100, Remaining: -1},
|
||||
{ID: 61, Name: "维达尔", Weight: 100, Remaining: -1},
|
||||
{ID: 62, Name: "玉衡星6号", Weight: 400, Remaining: -1},
|
||||
{ID: 63, Name: "马克兔", Weight: 600, Remaining: -1},
|
||||
{ID: 64, Name: "00高达", Weight: 700, Remaining: -1},
|
||||
{ID: 65, Name: "SD随机款", Weight: 1100, Remaining: -1},
|
||||
{ID: 66, Name: "兰博基尼", Weight: 3000, Remaining: -1},
|
||||
{ID: 67, Name: "高达徽章", Weight: 28000, Remaining: -1},
|
||||
{ID: 68, Name: "冰箱贴", Weight: 65800, Remaining: -1},
|
||||
}
|
||||
|
||||
activityID := int64(1)
|
||||
|
||||
// 2. 生成随机离散位置
|
||||
state, err := GenerateDiscretePositions(activityID, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("生成随机离散位置失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 设置到缓存
|
||||
s.discreteCache.Set(state)
|
||||
|
||||
t.Logf("总权重: %d", state.TotalWeight)
|
||||
|
||||
// 4. 模拟多次抽奖
|
||||
simulateCount := 1000
|
||||
results := make(map[int64]int)
|
||||
|
||||
for i := 0; i < simulateCount; i++ {
|
||||
// 模拟随机值生成(实际用crypto/rand)
|
||||
randValue := int32(i % int(state.TotalWeight))
|
||||
|
||||
// 查找对应的奖品
|
||||
prizeMap := make(map[int64]*model.LivestreamPrizes)
|
||||
for _, p := range prizes {
|
||||
prizeMap[p.ID] = p
|
||||
}
|
||||
|
||||
// 二分查找
|
||||
found := false
|
||||
for prizeID, positions := range state.PrizePosMap {
|
||||
for _, pos := range positions {
|
||||
if pos == randValue {
|
||||
results[prizeID]++
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 统计结果
|
||||
t.Log("\n========== 随机离散抽奖统计结果 ==========")
|
||||
t.Logf("模拟次数: %d", simulateCount)
|
||||
t.Log("")
|
||||
|
||||
totalWeight := int32(0)
|
||||
for _, p := range prizes {
|
||||
totalWeight += p.Weight
|
||||
}
|
||||
|
||||
totalCount := 0
|
||||
for _, p := range prizes {
|
||||
count := results[p.ID]
|
||||
totalCount += count
|
||||
theoryProb := float64(p.Weight) / float64(totalWeight) * 100
|
||||
actualProb := float64(count) / float64(simulateCount) * 100
|
||||
diff := actualProb - theoryProb
|
||||
|
||||
t.Logf("%-20s 权重:%6d 理论:%6.2f%% 实际:%6.2f%% 偏差:%+6.2f%% 次数:%5d",
|
||||
p.Name, p.Weight, theoryProb, actualProb, diff, count)
|
||||
}
|
||||
|
||||
t.Logf("\n总计中奖: %d", totalCount)
|
||||
|
||||
// 6. 验证概率偏差(允许的误差范围:±2%)
|
||||
t.Run("概率偏差检查", func(t *testing.T) {
|
||||
for _, p := range prizes {
|
||||
count := results[p.ID]
|
||||
theoryProb := float64(p.Weight) / float64(totalWeight) * 100
|
||||
actualProb := float64(count) / float64(simulateCount) * 100
|
||||
diff := actualProb - theoryProb
|
||||
|
||||
// 大奖(权重100)允许更大偏差
|
||||
threshold := 2.0
|
||||
if p.Weight <= 100 {
|
||||
threshold = 5.0 // 大奖允许5%偏差
|
||||
}
|
||||
|
||||
if diff > threshold || diff < -threshold {
|
||||
t.Errorf("%s 概率偏差过大: 理论%.2f%% 实际%.2f%% 偏差%.2f%% (阈值±%.1f%%)",
|
||||
p.Name, theoryProb, actualProb, diff, threshold)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDrawVsOriginal 对比新旧方案
|
||||
func TestDrawVsOriginal(t *testing.T) {
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "大奖", Weight: 100, Remaining: -1},
|
||||
{ID: 2, Name: "中奖", Weight: 3000, Remaining: -1},
|
||||
{ID: 3, Name: "小奖", Weight: 3000, Remaining: -1},
|
||||
{ID: 4, Name: "冰箱贴", Weight: 65800, Remaining: -1},
|
||||
}
|
||||
|
||||
activityID := int64(999)
|
||||
|
||||
// 原始连续区间方案
|
||||
t.Log("\n========== 方案对比 ==========")
|
||||
t.Log("【原始连续区间方案】")
|
||||
t.Log("大奖区间: [0, 99] (0.15%)")
|
||||
t.Log("中奖区间: [100, 3099] (4.48%)")
|
||||
t.Log("小奖区间: [3100, 6099] (4.48%)")
|
||||
t.Log("冰箱贴区间: [6100, 71899] (97.02%)")
|
||||
t.Log("")
|
||||
|
||||
// 随机离散方案
|
||||
state, err := GenerateDiscretePositions(activityID, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("生成失败: %v", err)
|
||||
}
|
||||
|
||||
t.Log("【随机离散方案】")
|
||||
for _, p := range prizes {
|
||||
positions := state.PrizePosMap[p.ID]
|
||||
t.Logf("%s: %d个随机位置", p.Name, len(positions))
|
||||
}
|
||||
|
||||
// 位置分布示例
|
||||
t.Log("\n【位置分布示例】")
|
||||
if pos, ok := state.PrizePosMap[1]; ok && len(pos) > 0 {
|
||||
sample := make([]int32, 0, 10)
|
||||
for i := 0; i < 10 && i < len(pos); i++ {
|
||||
sample = append(sample, pos[i])
|
||||
}
|
||||
t.Logf("大奖前10个位置: %v", sample)
|
||||
}
|
||||
if pos, ok := state.PrizePosMap[4]; ok && len(pos) > 0 {
|
||||
sample := make([]int32, 0, 10)
|
||||
for i := 0; i < 10 && i < len(pos); i++ {
|
||||
sample = append(sample, pos[i])
|
||||
}
|
||||
t.Logf("冰箱贴前10个位置: %v", sample)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheOperations 测试缓存操作
|
||||
func TestCacheOperations(t *testing.T) {
|
||||
cache := NewActivityDiscreteCache()
|
||||
|
||||
// 创建测试状态
|
||||
state := &ActivityDiscreteState{
|
||||
ActivityID: 1,
|
||||
TotalWeight: 1000,
|
||||
PrizePosMap: map[int64][]int32{
|
||||
1: {0, 10, 20, 30, 40},
|
||||
2: {5, 15, 25, 35, 45},
|
||||
},
|
||||
}
|
||||
|
||||
// 测试Set
|
||||
cache.Set(state)
|
||||
|
||||
// 测试Get
|
||||
got, ok := cache.Get(1)
|
||||
if !ok {
|
||||
t.Fatal("应该能找到缓存")
|
||||
}
|
||||
if got.ActivityID != 1 {
|
||||
t.Errorf("ActivityID不匹配: 期望1, 实际%d", got.ActivityID)
|
||||
}
|
||||
|
||||
// 测试Delete
|
||||
cache.Delete(1)
|
||||
_, ok = cache.Get(1)
|
||||
if ok {
|
||||
t.Error("删除后应该找不到")
|
||||
}
|
||||
|
||||
t.Log("缓存操作测试通过")
|
||||
}
|
||||
|
||||
// TestSelectPrizeByDiscreteWithList 测试带奖品列表的选择
|
||||
func TestSelectPrizeByDiscreteWithList(t *testing.T) {
|
||||
prizes := []*model.LivestreamPrizes{
|
||||
{ID: 1, Name: "大奖", Weight: 100, Remaining: -1},
|
||||
{ID: 2, Name: "小奖", Weight: 3000, Remaining: -1},
|
||||
}
|
||||
|
||||
state, err := GenerateDiscretePositions(1, prizes)
|
||||
if err != nil {
|
||||
t.Fatalf("生成失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试有效随机值
|
||||
for randValue := int32(0); randValue < state.TotalWeight; randValue++ {
|
||||
prize, _, err := SelectPrizeByDiscreteWithList(state, prizes, randValue)
|
||||
if err != nil {
|
||||
t.Errorf("随机值%d查找失败: %v", randValue, err)
|
||||
continue
|
||||
}
|
||||
if prize == nil {
|
||||
t.Errorf("随机值%d返回空奖品", randValue)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 测试边界值
|
||||
t.Run("边界值测试", func(t *testing.T) {
|
||||
// 超出范围
|
||||
_, _, err := SelectPrizeByDiscreteWithList(state, prizes, state.TotalWeight)
|
||||
if err == nil {
|
||||
t.Error("超出范围应该返回错误")
|
||||
}
|
||||
})
|
||||
|
||||
t.Logf("成功测试了 %d 个随机值", state.TotalWeight)
|
||||
}
|
||||
@ -61,6 +61,7 @@ type service struct {
|
||||
logger logger.CustomLogger
|
||||
repo mysql.Repo
|
||||
ticketSvc game.TicketService // 新增:游戏资格服务
|
||||
discreteCache *ActivityDiscreteCache // 随机离散位置缓存
|
||||
}
|
||||
|
||||
// New 创建直播间服务
|
||||
@ -69,6 +70,7 @@ func New(l logger.CustomLogger, repo mysql.Repo, ticketSvc game.TicketService) S
|
||||
logger: l,
|
||||
repo: repo,
|
||||
ticketSvc: ticketSvc,
|
||||
discreteCache: NewActivityDiscreteCache(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -296,25 +298,24 @@ func (s *service) CreatePrizes(ctx context.Context, activityID int64, prizes []C
|
||||
|
||||
var models []*model.LivestreamPrizes
|
||||
for _, p := range prizes {
|
||||
remaining := p.Quantity
|
||||
if remaining < 0 {
|
||||
remaining = -1
|
||||
}
|
||||
models = append(models, &model.LivestreamPrizes{
|
||||
ActivityID: activityID,
|
||||
Name: p.Name,
|
||||
Image: p.Image,
|
||||
Weight: p.Weight,
|
||||
Quantity: p.Quantity,
|
||||
Remaining: remaining,
|
||||
Level: p.Level,
|
||||
ProductID: p.ProductID,
|
||||
CostPrice: p.CostPrice,
|
||||
Sort: 0, // Default sort value as it's removed from input
|
||||
Sort: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return s.repo.GetDbW().WithContext(ctx).Create(&models).Error
|
||||
if err := s.repo.GetDbW().WithContext(ctx).Create(&models).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.regenerateDiscretePositions(ctx, activityID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) ListPrizes(ctx context.Context, activityID int64) ([]*model.LivestreamPrizes, error) {
|
||||
@ -352,7 +353,6 @@ func (s *service) UpdatePrize(ctx context.Context, prizeID int64, input UpdatePr
|
||||
// I will assume standard non-zero checks for strings and >0 or specific logic for ints.
|
||||
// If strictly following "compilation fix", I replacing nil checks with value checks.
|
||||
updates["quantity"] = input.Quantity
|
||||
updates["remaining"] = input.Quantity
|
||||
}
|
||||
if input.Level > 0 {
|
||||
updates["level"] = input.Level
|
||||
@ -378,69 +378,6 @@ func (s *service) DeletePrize(ctx context.Context, prizeID int64) error {
|
||||
|
||||
// ========== 抽奖逻辑 ==========
|
||||
|
||||
// selectPrizeByWeight 根据随机值和权重选择奖品(不考虑库存)
|
||||
func selectPrizeByWeight(prizes []*model.LivestreamPrizes, randValue int64) *model.LivestreamPrizes {
|
||||
var cumulative int64
|
||||
for _, p := range prizes {
|
||||
cumulative += int64(p.Weight)
|
||||
if randValue < cumulative {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if len(prizes) > 0 {
|
||||
return prizes[len(prizes)-1]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findFallbackPrize 找到权重最大的有库存奖品作为兜底
|
||||
func findFallbackPrize(prizes []*model.LivestreamPrizes) *model.LivestreamPrizes {
|
||||
var fallback *model.LivestreamPrizes
|
||||
for _, p := range prizes {
|
||||
if p.Remaining == 0 {
|
||||
continue
|
||||
}
|
||||
if fallback == nil || p.Weight > fallback.Weight {
|
||||
fallback = p
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// drawWithFallback 抽奖:抽中售罄奖品时,穿透到权重最大的有库存奖品
|
||||
func (s *service) drawWithFallback(prizes []*model.LivestreamPrizes, totalWeight int64) (*model.LivestreamPrizes, int64, error) {
|
||||
randBig, err := rand.Int(rand.Reader, big.NewInt(totalWeight))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("生成随机数失败: %w", err)
|
||||
}
|
||||
randValue := randBig.Int64()
|
||||
|
||||
selected := selectPrizeByWeight(prizes, randValue)
|
||||
if selected == nil {
|
||||
return nil, 0, fmt.Errorf("奖品选择失败")
|
||||
}
|
||||
|
||||
// 有库存,直接返回
|
||||
if selected.Remaining == -1 || selected.Remaining > 0 {
|
||||
return selected, randValue, nil
|
||||
}
|
||||
|
||||
// 售罄,穿透到权重最大的有库存奖品
|
||||
fallback := findFallbackPrize(prizes)
|
||||
if fallback == nil {
|
||||
return nil, 0, fmt.Errorf("没有可用奖品")
|
||||
}
|
||||
|
||||
s.logger.Info("抽中售罄奖品,穿透到兜底奖品",
|
||||
zap.Int64("original_prize_id", selected.ID),
|
||||
zap.String("original_prize_name", selected.Name),
|
||||
zap.Int64("fallback_prize_id", fallback.ID),
|
||||
zap.String("fallback_prize_name", fallback.Name),
|
||||
)
|
||||
|
||||
return fallback, randValue, nil
|
||||
}
|
||||
|
||||
func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error) {
|
||||
// 0. 检查黑名单
|
||||
if input.DouyinUserID != "" {
|
||||
@ -463,24 +400,16 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error
|
||||
return nil, fmt.Errorf("没有配置奖品")
|
||||
}
|
||||
|
||||
// 2. 计算总权重(所有奖品都参与,保持概率恒定)
|
||||
// 2. 计算总权重
|
||||
var totalWeight int64
|
||||
var hasAvailable bool
|
||||
for _, p := range prizes {
|
||||
totalWeight += int64(p.Weight)
|
||||
if p.Remaining != 0 { // -1 表示无限,>0 表示有库存
|
||||
hasAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
if totalWeight == 0 {
|
||||
return nil, fmt.Errorf("奖品权重配置异常")
|
||||
}
|
||||
|
||||
if !hasAvailable {
|
||||
return nil, fmt.Errorf("没有可用奖品(全部售罄)")
|
||||
}
|
||||
|
||||
// 3. 生成随机种子(用于凭证)
|
||||
seedBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(seedBytes); err != nil {
|
||||
@ -489,25 +418,14 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error
|
||||
seedHash := sha256.Sum256(seedBytes)
|
||||
seedHex := hex.EncodeToString(seedHash[:])
|
||||
|
||||
// 4. 穿透抽奖:抽中售罄奖品时给权重最大的有库存奖品
|
||||
selectedPrize, randValue, err := s.drawWithFallback(prizes, totalWeight)
|
||||
selectedPrize, randValue, err := s.drawWithDiscreteRandom(ctx, input.ActivityID, prizes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. 事务:扣减库存 + 记录中奖
|
||||
// 6. 记录中奖
|
||||
var drawLog *model.LivestreamDrawLogs
|
||||
err = s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 扣减库存(仅当 remaining > 0 时)
|
||||
if selectedPrize.Remaining > 0 {
|
||||
result := tx.Model(&model.LivestreamPrizes{}).
|
||||
Where("id = ? AND remaining > 0", selectedPrize.ID).
|
||||
Update("remaining", gorm.Expr("remaining - 1"))
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("库存不足")
|
||||
}
|
||||
}
|
||||
|
||||
// 记录中奖
|
||||
drawLog = &model.LivestreamDrawLogs{
|
||||
ActivityID: input.ActivityID,
|
||||
@ -518,6 +436,7 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error
|
||||
DouyinUserID: input.DouyinUserID,
|
||||
UserNickname: input.UserNickname,
|
||||
PrizeName: selectedPrize.Name,
|
||||
ProductID: selectedPrize.ProductID,
|
||||
Level: selectedPrize.Level,
|
||||
SeedHash: seedHex,
|
||||
RandValue: randValue,
|
||||
@ -670,3 +589,56 @@ func generateAccessCode() string {
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (s *service) regenerateDiscretePositions(ctx context.Context, activityID int64) {
|
||||
prizes, err := s.ListPrizes(ctx, activityID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取奖品列表失败", zap.Error(err), zap.Int64("activity_id", activityID))
|
||||
return
|
||||
}
|
||||
|
||||
if len(prizes) == 0 {
|
||||
s.discreteCache.Delete(activityID)
|
||||
return
|
||||
}
|
||||
|
||||
state, err := GenerateDiscretePositions(activityID, prizes)
|
||||
if err != nil {
|
||||
s.logger.Error("生成随机离散位置失败", zap.Error(err), zap.Int64("activity_id", activityID))
|
||||
return
|
||||
}
|
||||
|
||||
s.discreteCache.Set(state)
|
||||
s.logger.Info("随机离散位置已生成",
|
||||
zap.Int64("activity_id", activityID),
|
||||
zap.Int32("total_weight", state.TotalWeight),
|
||||
zap.Int("prize_count", len(prizes)),
|
||||
)
|
||||
}
|
||||
|
||||
// drawWithDiscreteRandom 使用随机离散位置抽奖
|
||||
func (s *service) drawWithDiscreteRandom(ctx context.Context, activityID int64, prizes []*model.LivestreamPrizes) (*model.LivestreamPrizes, int64, error) {
|
||||
state, ok := s.discreteCache.Get(activityID)
|
||||
if !ok {
|
||||
s.logger.Warn("随机离散位置未初始化,重新生成", zap.Int64("activity_id", activityID))
|
||||
s.regenerateDiscretePositions(ctx, activityID)
|
||||
state, ok = s.discreteCache.Get(activityID)
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("无法获取随机离散位置")
|
||||
}
|
||||
}
|
||||
|
||||
randBig, err := rand.Int(rand.Reader, big.NewInt(int64(state.TotalWeight)))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("生成随机数失败: %w", err)
|
||||
}
|
||||
randValue := int32(randBig.Int64())
|
||||
|
||||
selected, _, err := SelectPrizeByDiscreteWithList(state, prizes, randValue)
|
||||
if err != nil {
|
||||
s.logger.Error("随机离散抽奖失败", zap.Error(err), zap.Int64("activity_id", activityID))
|
||||
return nil, int64(randValue), err
|
||||
}
|
||||
|
||||
return selected, int64(randValue), nil
|
||||
}
|
||||
|
||||
31
migrations/20260203_add_product_id_to_draw_logs.sql
Normal file
31
migrations/20260203_add_product_id_to_draw_logs.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- 添加 product_id 字段到 livestream_draw_logs 表
|
||||
-- 用于直接记录奖品关联的商品ID,避免依赖 livestream_prizes 表
|
||||
|
||||
ALTER TABLE livestream_draw_logs
|
||||
ADD COLUMN product_id BIGINT NULL COMMENT '关联商品ID(快照)' AFTER prize_name;
|
||||
|
||||
-- 方式1: 从当前有效的 livestream_prizes 关联回填
|
||||
UPDATE livestream_draw_logs ldl
|
||||
JOIN livestream_prizes lp ON lp.id = ldl.prize_id
|
||||
SET ldl.product_id = lp.product_id
|
||||
WHERE ldl.product_id IS NULL AND lp.product_id IS NOT NULL;
|
||||
|
||||
-- 方式2: 通过奖品名称精确匹配 products 表回填(针对奖品已删除的历史记录)
|
||||
UPDATE livestream_draw_logs ldl
|
||||
JOIN products p ON p.name = ldl.prize_name
|
||||
SET ldl.product_id = p.id
|
||||
WHERE ldl.product_id IS NULL AND p.deleted_at IS NULL;
|
||||
|
||||
-- 方式3: 通过奖品名称模糊匹配(处理名称略有差异的情况)
|
||||
-- 注意:这个查询可能匹配到多个商品,使用 MIN(p.id) 取第一个匹配
|
||||
UPDATE livestream_draw_logs ldl
|
||||
JOIN (
|
||||
SELECT ldl2.id as log_id, MIN(p.id) as product_id
|
||||
FROM livestream_draw_logs ldl2
|
||||
JOIN products p ON p.name LIKE CONCAT('%', ldl2.prize_name, '%')
|
||||
OR ldl2.prize_name LIKE CONCAT('%', p.name, '%')
|
||||
WHERE ldl2.product_id IS NULL AND p.deleted_at IS NULL
|
||||
GROUP BY ldl2.id
|
||||
) matched ON matched.log_id = ldl.id
|
||||
SET ldl.product_id = matched.product_id
|
||||
WHERE ldl.product_id IS NULL;
|
||||
Loading…
x
Reference in New Issue
Block a user