统计更新
This commit is contained in:
parent
9214501756
commit
9eea272d69
@ -257,11 +257,11 @@ func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
var lsStats []lsStat
|
var lsStats []lsStat
|
||||||
lsQuery := db.Table(model.TableNameLivestreamDrawLogs).
|
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_draw_logs.product_id").
|
||||||
Joins("JOIN products ON products.id = livestream_prizes.product_id").
|
|
||||||
Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount").
|
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.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" {
|
if req.RangeType != "all" {
|
||||||
lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start).
|
lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start).
|
||||||
|
|||||||
@ -157,62 +157,44 @@ func (h *handler) GetLivestreamStats() core.HandlerFunc {
|
|||||||
refundedShopOrderIDs[oid] = true
|
refundedShopOrderIDs[oid] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 计算成本(只统计未退款订单的奖品成本)
|
// 4. 计算成本(使用 draw_logs.product_id 直接关联 products 表)
|
||||||
prizeIDCountMap := make(map[int64]int64)
|
// 收集未退款订单的 product_id 和对应数量
|
||||||
|
productIDCountMap := make(map[int64]int64)
|
||||||
for _, log := range drawLogs {
|
for _, log := range drawLogs {
|
||||||
// 排除已退款的订单 (检查 douyin_orders 状态)
|
// 排除已退款的订单
|
||||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||||
continue
|
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
|
var totalCost int64
|
||||||
prizeCostMap := make(map[int64]int64)
|
productCostMap := make(map[int64]int64)
|
||||||
if len(prizeIDs) > 0 {
|
if len(productIDCountMap) > 0 {
|
||||||
var prizes []model.LivestreamPrizes
|
productIDs := make([]int64, 0, len(productIDCountMap))
|
||||||
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
|
for pid := range productIDCountMap {
|
||||||
|
productIDs = append(productIDs, pid)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(productIDsNeedingFallback) > 0 {
|
var products []model.Products
|
||||||
var products []model.Products
|
h.repo.GetDbR().Where("id IN ?", productIDs).Find(&products)
|
||||||
h.repo.GetDbR().Where("id IN ?", productIDsNeedingFallback).Find(&products)
|
for _, p := range products {
|
||||||
productPriceMap := make(map[int64]int64)
|
productCostMap[p.ID] = p.Price
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for prizeID, count := range prizeIDCountMap {
|
for productID, count := range productIDCountMap {
|
||||||
if cost, ok := prizeCostMap[prizeID]; ok {
|
if cost, ok := productCostMap[productID]; ok {
|
||||||
totalCost += cost * count
|
totalCost += cost * count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建 productID -> cost 映射供每日统计使用
|
||||||
|
prizeCostMap := productCostMap
|
||||||
|
|
||||||
// 5. 按天分组统计
|
// 5. 按天分组统计
|
||||||
dailyMap := make(map[string]*dailyLivestreamStats)
|
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 {
|
for _, log := range drawLogs {
|
||||||
// 排除退款订单
|
// 排除退款订单
|
||||||
if refundedShopOrderIDs[log.ShopOrderID] {
|
if refundedShopOrderIDs[log.ShopOrderID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if log.ProductID <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dateKey := log.CreatedAt.Format("2006-01-02")
|
dateKey := log.CreatedAt.Format("2006-01-02")
|
||||||
ds := dailyMap[dateKey]
|
ds := dailyMap[dateKey]
|
||||||
if ds != nil {
|
if ds != nil {
|
||||||
if cost, ok := prizeCostMap[log.PrizeID]; ok {
|
if cost, ok := prizeCostMap[log.ProductID]; ok {
|
||||||
ds.TotalCost += cost
|
ds.TotalCost += cost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type LivestreamDrawLogs struct {
|
|||||||
DouyinUserID string `gorm:"column:douyin_user_id;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID
|
DouyinUserID string `gorm:"column:douyin_user_id;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID
|
||||||
UserNickname string `gorm:"column:user_nickname;comment:用户昵称" json:"user_nickname"` // 用户昵称
|
UserNickname string `gorm:"column:user_nickname;comment:用户昵称" json:"user_nickname"` // 用户昵称
|
||||||
PrizeName string `gorm:"column:prize_name;comment:中奖奖品名称快照" json:"prize_name"` // 中奖奖品名称快照
|
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"` // 奖品等级
|
Level int32 `gorm:"column:level;default:1;comment:奖品等级" json:"level"` // 奖品等级
|
||||||
SeedHash string `gorm:"column:seed_hash;comment:哈希种子" json:"seed_hash"` // 哈希种子
|
SeedHash string `gorm:"column:seed_hash;comment:哈希种子" json:"seed_hash"` // 哈希种子
|
||||||
RandValue int64 `gorm:"column:rand_value;comment:随机值" json:"rand_value"` // 随机值
|
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)
|
||||||
|
}
|
||||||
@ -58,17 +58,19 @@ type Service interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
logger logger.CustomLogger
|
logger logger.CustomLogger
|
||||||
repo mysql.Repo
|
repo mysql.Repo
|
||||||
ticketSvc game.TicketService // 新增:游戏资格服务
|
ticketSvc game.TicketService // 新增:游戏资格服务
|
||||||
|
discreteCache *ActivityDiscreteCache // 随机离散位置缓存
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建直播间服务
|
// New 创建直播间服务
|
||||||
func New(l logger.CustomLogger, repo mysql.Repo, ticketSvc game.TicketService) Service {
|
func New(l logger.CustomLogger, repo mysql.Repo, ticketSvc game.TicketService) Service {
|
||||||
return &service{
|
return &service{
|
||||||
logger: l,
|
logger: l,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
ticketSvc: ticketSvc,
|
ticketSvc: ticketSvc,
|
||||||
|
discreteCache: NewActivityDiscreteCache(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,25 +298,24 @@ func (s *service) CreatePrizes(ctx context.Context, activityID int64, prizes []C
|
|||||||
|
|
||||||
var models []*model.LivestreamPrizes
|
var models []*model.LivestreamPrizes
|
||||||
for _, p := range prizes {
|
for _, p := range prizes {
|
||||||
remaining := p.Quantity
|
|
||||||
if remaining < 0 {
|
|
||||||
remaining = -1
|
|
||||||
}
|
|
||||||
models = append(models, &model.LivestreamPrizes{
|
models = append(models, &model.LivestreamPrizes{
|
||||||
ActivityID: activityID,
|
ActivityID: activityID,
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Image: p.Image,
|
Image: p.Image,
|
||||||
Weight: p.Weight,
|
Weight: p.Weight,
|
||||||
Quantity: p.Quantity,
|
|
||||||
Remaining: remaining,
|
|
||||||
Level: p.Level,
|
Level: p.Level,
|
||||||
ProductID: p.ProductID,
|
ProductID: p.ProductID,
|
||||||
CostPrice: p.CostPrice,
|
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) {
|
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.
|
// 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.
|
// If strictly following "compilation fix", I replacing nil checks with value checks.
|
||||||
updates["quantity"] = input.Quantity
|
updates["quantity"] = input.Quantity
|
||||||
updates["remaining"] = input.Quantity
|
|
||||||
}
|
}
|
||||||
if input.Level > 0 {
|
if input.Level > 0 {
|
||||||
updates["level"] = input.Level
|
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) {
|
func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error) {
|
||||||
// 0. 检查黑名单
|
// 0. 检查黑名单
|
||||||
if input.DouyinUserID != "" {
|
if input.DouyinUserID != "" {
|
||||||
@ -463,24 +400,16 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error
|
|||||||
return nil, fmt.Errorf("没有配置奖品")
|
return nil, fmt.Errorf("没有配置奖品")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 计算总权重(所有奖品都参与,保持概率恒定)
|
// 2. 计算总权重
|
||||||
var totalWeight int64
|
var totalWeight int64
|
||||||
var hasAvailable bool
|
|
||||||
for _, p := range prizes {
|
for _, p := range prizes {
|
||||||
totalWeight += int64(p.Weight)
|
totalWeight += int64(p.Weight)
|
||||||
if p.Remaining != 0 { // -1 表示无限,>0 表示有库存
|
|
||||||
hasAvailable = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalWeight == 0 {
|
if totalWeight == 0 {
|
||||||
return nil, fmt.Errorf("奖品权重配置异常")
|
return nil, fmt.Errorf("奖品权重配置异常")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasAvailable {
|
|
||||||
return nil, fmt.Errorf("没有可用奖品(全部售罄)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 生成随机种子(用于凭证)
|
// 3. 生成随机种子(用于凭证)
|
||||||
seedBytes := make([]byte, 32)
|
seedBytes := make([]byte, 32)
|
||||||
if _, err := rand.Read(seedBytes); err != nil {
|
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)
|
seedHash := sha256.Sum256(seedBytes)
|
||||||
seedHex := hex.EncodeToString(seedHash[:])
|
seedHex := hex.EncodeToString(seedHash[:])
|
||||||
|
|
||||||
// 4. 穿透抽奖:抽中售罄奖品时给权重最大的有库存奖品
|
selectedPrize, randValue, err := s.drawWithDiscreteRandom(ctx, input.ActivityID, prizes)
|
||||||
selectedPrize, randValue, err := s.drawWithFallback(prizes, totalWeight)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 事务:扣减库存 + 记录中奖
|
// 6. 记录中奖
|
||||||
var drawLog *model.LivestreamDrawLogs
|
var drawLog *model.LivestreamDrawLogs
|
||||||
err = s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
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{
|
drawLog = &model.LivestreamDrawLogs{
|
||||||
ActivityID: input.ActivityID,
|
ActivityID: input.ActivityID,
|
||||||
@ -518,6 +436,7 @@ func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error
|
|||||||
DouyinUserID: input.DouyinUserID,
|
DouyinUserID: input.DouyinUserID,
|
||||||
UserNickname: input.UserNickname,
|
UserNickname: input.UserNickname,
|
||||||
PrizeName: selectedPrize.Name,
|
PrizeName: selectedPrize.Name,
|
||||||
|
ProductID: selectedPrize.ProductID,
|
||||||
Level: selectedPrize.Level,
|
Level: selectedPrize.Level,
|
||||||
SeedHash: seedHex,
|
SeedHash: seedHex,
|
||||||
RandValue: randValue,
|
RandValue: randValue,
|
||||||
@ -670,3 +589,56 @@ func generateAccessCode() string {
|
|||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
return hex.EncodeToString(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