package livestream import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "math/big" "time" "bindbox-game/internal/pkg/logger" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/repository/mysql/model" "bindbox-game/internal/service/game" "go.uber.org/zap" "gorm.io/gorm" ) // Service 直播间游戏服务接口 type Service interface { // CreateActivity 创建直播间活动 CreateActivity(ctx context.Context, input CreateActivityInput) (*model.LivestreamActivities, error) // UpdateActivity 更新活动 UpdateActivity(ctx context.Context, id int64, input UpdateActivityInput) error // GetActivity 获取活动详情 GetActivity(ctx context.Context, id int64) (*model.LivestreamActivities, error) // GetActivityByAccessCode 根据访问码获取活动 GetActivityByAccessCode(ctx context.Context, code string) (*model.LivestreamActivities, error) // ListActivities 活动列表 ListActivities(ctx context.Context, page, pageSize int, status *int32) ([]*model.LivestreamActivities, int64, error) // DeleteActivity 删除活动 DeleteActivity(ctx context.Context, id int64) error // CreatePrizes 批量创建奖品 CreatePrizes(ctx context.Context, activityID int64, prizes []CreatePrizeInput) error // ListPrizes 获取活动奖品列表 ListPrizes(ctx context.Context, activityID int64) ([]*model.LivestreamPrizes, error) // UpdatePrize 更新奖品 UpdatePrize(ctx context.Context, prizeID int64, input UpdatePrizeInput) error // UpdatePrizeSortOrder 更新奖品排序 UpdatePrizeSortOrder(ctx context.Context, activityID int64, prizeIDs []int64) error // DeletePrize 删除奖品 DeletePrize(ctx context.Context, prizeID int64) error // Draw 执行抽奖 Draw(ctx context.Context, input DrawInput) (*DrawResult, error) // ListDrawLogs 获取中奖记录 ListDrawLogs(ctx context.Context, activityID int64, page, pageSize int, startTime, endTime *time.Time) ([]*model.LivestreamDrawLogs, int64, error) // GetActivityByProductID 根据抖店商品ID获取活动 GetActivityByProductID(ctx context.Context, productID string) (*model.LivestreamActivities, error) // GenerateCommitment 为活动生成承诺种子 GenerateCommitment(ctx context.Context, activityID int64) (int32, error) // GetCommitmentSummary 获取活动承诺摘要 GetCommitmentSummary(ctx context.Context, activityID int64) (*CommitmentSummary, error) } type service struct { logger logger.CustomLogger repo mysql.Repo ticketSvc game.TicketService // 新增:游戏资格服务 discreteCache *ActivityDiscreteCache // 随机离散位置缓存 } // New 创建直播间服务 func New(l logger.CustomLogger, repo mysql.Repo, ticketSvc game.TicketService) Service { return &service{ logger: l, repo: repo, ticketSvc: ticketSvc, discreteCache: NewActivityDiscreteCache(), } } // ========== Input/Output 结构体 ========== type CreateActivityInput struct { Name string StreamerName string StreamerContact string ChannelID int64 ChannelCode string DouyinProductID string OrderRewardType string OrderRewardQuantity int32 TicketPrice int64 StartTime *time.Time EndTime *time.Time } type UpdateActivityInput struct { Name string StreamerName string StreamerContact string ChannelID *int64 ChannelCode *string DouyinProductID string OrderRewardType string OrderRewardQuantity *int32 TicketPrice *int64 Status *int32 StartTime *time.Time EndTime *time.Time } type CreatePrizeInput struct { Name string Image string Weight int32 Quantity int32 Level int32 ProductID int64 `json:"product_id"` CostPrice int64 `json:"cost_price"` } type UpdatePrizeInput struct { ID int64 `json:"id"` Name string `json:"name"` Level int32 `json:"level"` Weight int32 `json:"weight"` Quantity int32 `json:"quantity"` Image string `json:"image"` ProductID int64 `json:"product_id"` CostPrice int64 `json:"cost_price"` } type DrawInput struct { ActivityID int64 DouyinOrderID int64 ShopOrderID string LocalUserID int64 DouyinUserID string UserNickname string } type DrawResult struct { Prize *model.LivestreamPrizes DrawLog *model.LivestreamDrawLogs SeedHash string Receipt *DrawReceipt } // CommitmentSummary 承诺摘要 type CommitmentSummary struct { SeedVersion int32 `json:"seed_version"` Algo string `json:"algo"` HasSeed bool `json:"has_seed"` LenSeed int `json:"len_seed_master"` LenHash int `json:"len_seed_hash"` SeedHashHex string `json:"seed_hash_hex"` // 种子哈希的十六进制表示(可公开) } // DrawReceipt 抽奖凭证 type DrawReceipt struct { SeedVersion int32 `json:"seed_version"` Timestamp int64 `json:"timestamp"` Nonce int64 `json:"nonce"` Signature string `json:"signature"` Algorithm string `json:"algorithm"` } // ========== 活动管理 ========== func (s *service) CreateActivity(ctx context.Context, input CreateActivityInput) (*model.LivestreamActivities, error) { // 生成唯一访问码 accessCode := generateAccessCode() activity := &model.LivestreamActivities{ Name: input.Name, StreamerName: input.StreamerName, StreamerContact: input.StreamerContact, ChannelID: input.ChannelID, ChannelCode: input.ChannelCode, AccessCode: accessCode, DouyinProductID: input.DouyinProductID, OrderRewardType: input.OrderRewardType, OrderRewardQuantity: input.OrderRewardQuantity, TicketPrice: int32(input.TicketPrice), Status: 1, } // 构建要插入的字段列表,排除空的时间字段 columns := []string{"name", "streamer_name", "streamer_contact", "access_code", "douyin_product_id", "order_reward_type", "order_reward_quantity", "ticket_price", "status"} if input.StartTime != nil { activity.StartTime = *input.StartTime columns = append(columns, "start_time") } if input.EndTime != nil { activity.EndTime = *input.EndTime columns = append(columns, "end_time") } if err := s.repo.GetDbW().WithContext(ctx).Select(columns).Create(activity).Error; err != nil { return nil, fmt.Errorf("创建直播间活动失败: %w", err) } return activity, nil } func (s *service) UpdateActivity(ctx context.Context, id int64, input UpdateActivityInput) error { updates := make(map[string]any) if input.Name != "" { updates["name"] = input.Name } if input.StreamerName != "" { updates["streamer_name"] = input.StreamerName } if input.StreamerContact != "" { updates["streamer_contact"] = input.StreamerContact } if input.ChannelID != nil { updates["channel_id"] = *input.ChannelID } if input.ChannelCode != nil { updates["channel_code"] = *input.ChannelCode } if input.DouyinProductID != "" { updates["douyin_product_id"] = input.DouyinProductID } if input.OrderRewardType != "" { updates["order_reward_type"] = input.OrderRewardType } if input.OrderRewardQuantity != nil { updates["order_reward_quantity"] = *input.OrderRewardQuantity } if input.TicketPrice != nil { updates["ticket_price"] = int32(*input.TicketPrice) } if input.Status != nil { updates["status"] = *input.Status } if input.StartTime != nil { updates["start_time"] = *input.StartTime } if input.EndTime != nil { updates["end_time"] = *input.EndTime } if len(updates) == 0 { return nil } return s.repo.GetDbW().WithContext(ctx).Model(&model.LivestreamActivities{}). Where("id = ?", id).Updates(updates).Error } func (s *service) GetActivity(ctx context.Context, id int64) (*model.LivestreamActivities, error) { var activity model.LivestreamActivities if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", id).First(&activity).Error; err != nil { return nil, err } return &activity, nil } func (s *service) GetActivityByAccessCode(ctx context.Context, code string) (*model.LivestreamActivities, error) { var activity model.LivestreamActivities if err := s.repo.GetDbR().WithContext(ctx).Where("access_code = ? AND deleted_at IS NULL", code).First(&activity).Error; err != nil { return nil, err } return &activity, nil } func (s *service) GetActivityByProductID(ctx context.Context, productID string) (*model.LivestreamActivities, error) { var activity model.LivestreamActivities if err := s.repo.GetDbR().WithContext(ctx). Where("douyin_product_id = ? AND status = 1 AND deleted_at IS NULL", productID). First(&activity).Error; err != nil { return nil, err } return &activity, nil } func (s *service) ListActivities(ctx context.Context, page, pageSize int, status *int32) ([]*model.LivestreamActivities, int64, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } db := s.repo.GetDbR().WithContext(ctx).Model(&model.LivestreamActivities{}).Where("deleted_at IS NULL") if status != nil { db = db.Where("status = ?", *status) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var list []*model.LivestreamActivities if err := db.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil { return nil, 0, err } return list, total, nil } func (s *service) DeleteActivity(ctx context.Context, id int64) error { return s.repo.GetDbW().WithContext(ctx).Delete(&model.LivestreamActivities{}, id).Error } // ========== 奖品管理 ========== func (s *service) CreatePrizes(ctx context.Context, activityID int64, prizes []CreatePrizeInput) error { if len(prizes) == 0 { return nil } var models []*model.LivestreamPrizes for _, p := range prizes { models = append(models, &model.LivestreamPrizes{ ActivityID: activityID, Name: p.Name, Image: p.Image, Weight: p.Weight, Level: p.Level, ProductID: p.ProductID, CostPrice: p.CostPrice, Sort: 0, }) } 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) { var list []*model.LivestreamPrizes if err := s.repo.GetDbR().WithContext(ctx). Where("activity_id = ?", activityID). Order("sort ASC, id ASC"). Find(&list).Error; err != nil { return nil, err } return list, nil } func (s *service) UpdatePrize(ctx context.Context, prizeID int64, input UpdatePrizeInput) error { updates := make(map[string]any) if input.Name != "" { updates["name"] = input.Name } if input.Image != "" { updates["image"] = input.Image } if input.Weight > 0 { updates["weight"] = input.Weight } if input.Quantity >= 0 { // Allow 0 quantity update? Assuming yes. But 0 is zero-value. If user wants to set 0, logic needs distinction. Assuming Partial Update with Value Types is tricky. For now check > -1 if possible or just non-zero if quantity can't be 0? Usually quantity update is explicit. // Since struct is Value type, we can't distinguish 0 from "not set". // User changed to Value type likely assuming "replace" or front-end sends all fields. // However, standard Update usually implies partial. // Let's assume > 0 for now or if we treat input as full replacement? // Given `UpdatePrizeInput` structure, it likely carries the full desired state or partial. // If partial, int fields are problematic. // Let's use > 0 for non-zero values, assuming 0 isn't a valid "change to" target for weight/level/quantity unless explicit. // Actually, quantity can be 0 (sold out). But if client sends 0 as "no change", we have bug. // Since user changed specific fields, they might be relying on frontend logic. // 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 } if input.Level > 0 { updates["level"] = input.Level } if input.ProductID > 0 { updates["product_id"] = input.ProductID } if input.CostPrice > 0 { // Assume cost price is positive? Or allow 0? Updates map approach usually omits if 0. updates["cost_price"] = input.CostPrice } if len(updates) == 0 { return nil } return s.repo.GetDbW().WithContext(ctx).Model(&model.LivestreamPrizes{}). Where("id = ?", prizeID).Updates(updates).Error } // UpdatePrizeSortOrder 更新奖品排序 func (s *service) UpdatePrizeSortOrder(ctx context.Context, activityID int64, prizeIDs []int64) error { if len(prizeIDs) == 0 { return nil } // 使用事务更新所有奖品的排序 return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { for i, prizeID := range prizeIDs { // 验证奖品属于该活动 var prize model.LivestreamPrizes if err := tx.Where("id = ? AND activity_id = ?", prizeID, activityID).First(&prize).Error; err != nil { return fmt.Errorf("奖品ID %d 不存在或不属于该活动", prizeID) } // 更新排序字段 if err := tx.Model(&model.LivestreamPrizes{}). Where("id = ?", prizeID). Update("sort", i).Error; err != nil { return err } } return nil }) } func (s *service) DeletePrize(ctx context.Context, prizeID int64) error { return s.repo.GetDbW().WithContext(ctx).Delete(&model.LivestreamPrizes{}, prizeID).Error } // ========== 抽奖逻辑 ========== func (s *service) Draw(ctx context.Context, input DrawInput) (*DrawResult, error) { // 0. 检查黑名单 if input.DouyinUserID != "" { var blacklistCount int64 if err := s.repo.GetDbR().WithContext(ctx). Table("douyin_blacklist"). Where("douyin_user_id = ? AND status = 1", input.DouyinUserID). Count(&blacklistCount).Error; err == nil && blacklistCount > 0 { return nil, fmt.Errorf("该用户已被列入黑名单,无法开奖") } } // 1. 获取所有奖品 prizes, err := s.ListPrizes(ctx, input.ActivityID) if err != nil { return nil, fmt.Errorf("获取奖品列表失败: %w", err) } if len(prizes) == 0 { return nil, fmt.Errorf("没有配置奖品") } // 2. 计算总权重 var totalWeight int64 for _, p := range prizes { totalWeight += int64(p.Weight) } if totalWeight == 0 { return nil, fmt.Errorf("奖品权重配置异常") } // 3. 生成随机种子(用于凭证) seedBytes := make([]byte, 32) if _, err := rand.Read(seedBytes); err != nil { return nil, fmt.Errorf("生成随机种子失败: %w", err) } seedHash := sha256.Sum256(seedBytes) seedHex := hex.EncodeToString(seedHash[:]) selectedPrize, randValue, err := s.drawWithDiscreteRandom(ctx, input.ActivityID, prizes) if err != nil { return nil, err } // 6. 记录中奖 var drawLog *model.LivestreamDrawLogs err = s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 记录中奖 drawLog = &model.LivestreamDrawLogs{ ActivityID: input.ActivityID, PrizeID: selectedPrize.ID, DouyinOrderID: input.DouyinOrderID, ShopOrderID: input.ShopOrderID, LocalUserID: input.LocalUserID, DouyinUserID: input.DouyinUserID, UserNickname: input.UserNickname, PrizeName: selectedPrize.Name, ProductID: selectedPrize.ProductID, Level: selectedPrize.Level, SeedHash: seedHex, RandValue: randValue, WeightsTotal: totalWeight, } return tx.Create(drawLog).Error }) if err != nil { s.logger.Error("直播间抽奖失败", zap.Error(err), zap.Int64("activity_id", input.ActivityID)) return nil, err } s.logger.Info("直播间抽奖成功", zap.Int64("activity_id", input.ActivityID), zap.Int64("prize_id", selectedPrize.ID), zap.String("prize_name", selectedPrize.Name), ) // 7. 生成可验证凭证 var receipt *DrawReceipt var activity model.LivestreamActivities if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", input.ActivityID).First(&activity).Error; err == nil { if len(activity.CommitmentSeedMaster) > 0 { ts := time.Now().UnixMilli() nonce := time.Now().UnixNano() // 构建签名载荷 payload := fmt.Sprintf("activity:%d|order:%s|draw:%d|ts:%d|nonce:%d", input.ActivityID, input.ShopOrderID, drawLog.ID, ts, nonce) // HMAC-SHA256 签名 mac := hmac.New(sha256.New, activity.CommitmentSeedMaster) mac.Write([]byte(payload)) sig := hex.EncodeToString(mac.Sum(nil)) receipt = &DrawReceipt{ SeedVersion: activity.CommitmentStateVersion, Timestamp: ts, Nonce: nonce, Signature: sig, Algorithm: "HMAC-SHA256", } } } return &DrawResult{ Prize: selectedPrize, DrawLog: drawLog, SeedHash: seedHex, Receipt: receipt, }, nil } func (s *service) ListDrawLogs(ctx context.Context, activityID int64, page, pageSize int, startTime, endTime *time.Time) ([]*model.LivestreamDrawLogs, int64, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } db := s.repo.GetDbR().WithContext(ctx).Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID) if startTime != nil { db = db.Where("created_at >= ?", startTime) } if endTime != nil { db = db.Where("created_at <= ?", endTime) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var list []*model.LivestreamDrawLogs if err := db.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil { return nil, 0, err } return list, total, nil } // ========== 承诺管理 ========== // GenerateCommitment 为活动生成承诺种子 func (s *service) GenerateCommitment(ctx context.Context, activityID int64) (int32, error) { // 获取当前版本号 var activity model.LivestreamActivities if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", activityID).First(&activity).Error; err != nil { return 0, fmt.Errorf("活动不存在: %w", err) } // 生成 32 字节随机种子 seed := make([]byte, 32) if _, err := rand.Read(seed); err != nil { return 0, fmt.Errorf("生成随机种子失败: %w", err) } // 计算 SHA256 哈希 seedHash := sha256.Sum256(seed) // 更新数据库 newVersion := activity.CommitmentStateVersion + 1 if err := s.repo.GetDbW().WithContext(ctx).Model(&model.LivestreamActivities{}). Where("id = ?", activityID). Updates(map[string]any{ "commitment_algo": "commit-v1", "commitment_seed_master": seed, "commitment_seed_hash": seedHash[:], "commitment_state_version": newVersion, }).Error; err != nil { return 0, fmt.Errorf("更新承诺失败: %w", err) } s.logger.Info("直播间活动承诺已生成", zap.Int64("activity_id", activityID), zap.Int32("version", newVersion), ) return newVersion, nil } // GetCommitmentSummary 获取活动承诺摘要 func (s *service) GetCommitmentSummary(ctx context.Context, activityID int64) (*CommitmentSummary, error) { var activity model.LivestreamActivities if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", activityID).First(&activity).Error; err != nil { return nil, fmt.Errorf("活动不存在: %w", err) } // 将种子哈希转为十六进制字符串 seedHashHex := "" if len(activity.CommitmentSeedHash) > 0 { seedHashHex = hex.EncodeToString(activity.CommitmentSeedHash) } return &CommitmentSummary{ SeedVersion: activity.CommitmentStateVersion, Algo: activity.CommitmentAlgo, HasSeed: len(activity.CommitmentSeedMaster) > 0, LenSeed: len(activity.CommitmentSeedMaster), LenHash: len(activity.CommitmentSeedHash), SeedHashHex: seedHashHex, }, nil } // ========== 辅助函数 ========== func generateAccessCode() string { b := make([]byte, 16) 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 }