2026-02-27 00:08:02 +08:00

685 lines
21 KiB
Go
Executable File

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
}