2026-02-02 23:56:01 +08:00

673 lines
21 KiB
Go

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
// 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 // 新增:游戏资格服务
}
// New 创建直播间服务
func New(l logger.CustomLogger, repo mysql.Repo, ticketSvc game.TicketService) Service {
return &service{
logger: l,
repo: repo,
ticketSvc: ticketSvc,
}
}
// ========== Input/Output 结构体 ==========
type CreateActivityInput struct {
Name string
StreamerName string
StreamerContact string
DouyinProductID string
OrderRewardType string
OrderRewardQuantity int32
TicketPrice int64
StartTime *time.Time
EndTime *time.Time
}
type UpdateActivityInput struct {
Name string
StreamerName string
StreamerContact 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,
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.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 {
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
})
}
return s.repo.GetDbW().WithContext(ctx).Create(&models).Error
}
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
updates["remaining"] = 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
}
func (s *service) DeletePrize(ctx context.Context, prizeID int64) error {
return s.repo.GetDbW().WithContext(ctx).Delete(&model.LivestreamPrizes{}, prizeID).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 != "" {
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
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 {
return nil, fmt.Errorf("生成随机种子失败: %w", err)
}
seedHash := sha256.Sum256(seedBytes)
seedHex := hex.EncodeToString(seedHash[:])
// 4. 穿透抽奖:抽中售罄奖品时给权重最大的有库存奖品
selectedPrize, randValue, err := s.drawWithFallback(prizes, totalWeight)
if err != nil {
return nil, err
}
// 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,
PrizeID: selectedPrize.ID,
DouyinOrderID: input.DouyinOrderID,
ShopOrderID: input.ShopOrderID,
LocalUserID: input.LocalUserID,
DouyinUserID: input.DouyinUserID,
UserNickname: input.UserNickname,
PrizeName: selectedPrize.Name,
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)
}