package services import ( "app/commons/constant" "app/commons/core" "app/commons/core/redisclient" "app/commons/i18n" "app/commons/model/entity" "crypto/rand" "encoding/hex" "errors" "fmt" "github.com/shopspring/decimal" "gorm.io/gorm" "math/big" "time" ) // RedPacketService 红包服务 type RedPacketService struct { CommonService } // GeneratePacketNo 生成红包编号 func (s *RedPacketService) GeneratePacketNo() string { b := make([]byte, 16) rand.Read(b) return fmt.Sprintf("RP%d%s", time.Now().Unix(), hex.EncodeToString(b)[:16]) } // CreateRedPacket 创建红包 func (s *RedPacketService) CreateRedPacket( userId int64, groupId string, packetType int, totalAmount decimal.Decimal, totalCount int, symbol string, blessingWords string, expireMinutes int, maxGrabAmount decimal.Decimal, lang string, ) (*entity.TgRedPacket, error) { // 参数校验 if totalAmount.LessThanOrEqual(decimal.Zero) { return nil, errors.New("红包金额必须大于0") } if totalCount <= 0 || totalCount > 100 { return nil, errors.New("红包个数必须在1-100之间") } if packetType != 1 && packetType != 2 { return nil, errors.New("红包类型错误") } // 管理员发红包(userId=0)跳过资产检查和扣款 isAdmin := userId == 0 if !isAdmin { // 检查用户资产 userAsset, err := s.GetAssetBySymbol(userId, symbol) if err != nil { return nil, errors.New("获取资产失败") } if userAsset.Balance.LessThan(totalAmount) { return nil, errors.New("余额不足") } } // 开始事务 var packet *entity.TgRedPacket err := s.DB().Transaction(func(tx *gorm.DB) error { // 扣除用户余额(转为冻结),管理员跳过 if !isAdmin { if err := s.ActionAsset(tx, userId, symbol, totalAmount.Neg(), totalAmount); err != nil { return fmt.Errorf("扣除余额失败: %w", err) } } // 计算过期时间 expireDuration := 10 * time.Minute // 默认10分钟 if expireMinutes > 0 { expireDuration = time.Duration(expireMinutes) * time.Minute } // 默认语言 if lang == "" { lang = "vi" } // 创建红包记录 packet = &entity.TgRedPacket{ PacketNo: s.GeneratePacketNo(), UserId: userId, GroupId: groupId, PacketType: packetType, TotalAmount: totalAmount, TotalCount: totalCount, GrabbedCount: 0, GrabbedAmount: decimal.Zero, RemainCount: totalCount, RemainAmount: totalAmount, Symbol: symbol, MaxGrabAmount: maxGrabAmount, Lang: lang, BlessingWords: blessingWords, Status: 1, // 进行中 ExpireAt: time.Now().Add(expireDuration), } if err := tx.Create(packet).Error; err != nil { return fmt.Errorf("创建红包失败: %w", err) } return nil }) if err != nil { return nil, err } return packet, nil } // GrabRedPacket 抢红包 func (s *RedPacketService) GrabRedPacket( packetNo string, userId int64, telegramId int64, telegramUsername string, ) (*entity.TgRedPacketRecord, error) { // 分布式锁(防止并发抢同一个红包) lockKey := fmt.Sprintf("grab_redpacket:%s", packetNo) lock, err := redisclient.LockWithTime(lockKey, 5) if err != nil { return nil, errors.New("系统繁忙,请稍后重试") } defer redisclient.UnlockSafe(lock) // 获取红包信息 var packet entity.TgRedPacket if err := s.DB().Where("packet_no = ?", packetNo).First(&packet).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("红包不存在") } return nil, err } // 检查红包状态 if packet.Status != 1 { return nil, errors.New("红包已结束") } if time.Now().After(packet.ExpireAt) { return nil, errors.New("红包已过期") } if packet.RemainCount <= 0 { return nil, errors.New("红包已抢完") } // 检查是否已经抢过(优先用telegramId判断,兼容未绑定平台账号的用户) var count int64 if telegramId > 0 { s.DB().Model(&entity.TgRedPacketRecord{}). Where("packet_id = ? AND telegram_id = ?", packet.Id, telegramId). Count(&count) } else { s.DB().Model(&entity.TgRedPacketRecord{}). Where("packet_id = ? AND user_id = ?", packet.Id, userId). Count(&count) } if count > 0 { return nil, errors.New("您已经抢过这个红包了") } // 开始事务 var record *entity.TgRedPacketRecord err = s.DB().Transaction(func(tx *gorm.DB) error { // 计算抢到的金额 amount := s.CalculateGrabAmount(&packet) // 先保存原始值(GORM Updates 会回写到 packet 对象,导致后续判断用到更新后的值) origGrabbedCount := packet.GrabbedCount origRemainCount := packet.RemainCount // 更新红包信息 updates := map[string]interface{}{ "grabbed_count": origGrabbedCount + 1, "grabbed_amount": packet.GrabbedAmount.Add(amount), "remain_count": origRemainCount - 1, "remain_amount": packet.RemainAmount.Sub(amount), } if err := tx.Model(&packet).Updates(updates).Error; err != nil { return fmt.Errorf("更新红包失败: %w", err) } // 创建抢红包记录 record = &entity.TgRedPacketRecord{ PacketId: packet.Id, PacketNo: packet.PacketNo, UserId: userId, TelegramId: telegramId, TelegramUsername: telegramUsername, Amount: amount, Sequence: int(origGrabbedCount) + 1, GrabbedAt: time.Now(), } if err := tx.Create(record).Error; err != nil { return fmt.Errorf("创建记录失败: %w", err) } // 增加用户余额,userId=0 时跳过(Telegram用户未绑定平台账号) if userId > 0 { // 确保用户有资产记录(不存在则创建) var assetCount int64 tx.Model(&entity.Asset{}).Where("user_id = ? AND symbol = ?", userId, packet.Symbol).Count(&assetCount) if assetCount == 0 { newAsset := &entity.Asset{ UserId: userId, Symbol: packet.Symbol, Balance: decimal.Zero, Frozen: decimal.Zero, } if err := tx.Create(newAsset).Error; err != nil { return fmt.Errorf("创建资产记录失败: %w", err) } } // 写流水 + 改资产 bs := constant.BsById(constant.BsRedPacketGrab) bs.ContextName = "红包编号" bs.ContextValue = packet.PacketNo bs.Desc = fmt.Sprintf("抢红包收入: %s %s, 红包: %s", amount.String(), packet.Symbol, packet.PacketNo) if err := s.GenBillAndActionAsset(tx, userId, packet.Symbol, amount, decimal.Zero, bs); err != nil { return fmt.Errorf("增加余额失败: %w", err) } } // 如果抢完了,更新红包状态(使用原始值判断,因为 GORM Updates 已回写 packet) if origRemainCount == 1 { now := time.Now() if err := tx.Model(&packet).Updates(map[string]interface{}{ "status": 2, // 已抢完 "completed_at": &now, }).Error; err != nil { return fmt.Errorf("更新完成状态失败: %w", err) } // 异步计算手气最佳 go s.CalculateBestLuck(packet.Id) } return nil }) if err != nil { return nil, err } return record, nil } // CalculateGrabAmount 计算抢红包金额 func (s *RedPacketService) CalculateGrabAmount(packet *entity.TgRedPacket) decimal.Decimal { var amount decimal.Decimal // 最后一个红包:剩余所有金额 if packet.RemainCount == 1 { amount = packet.RemainAmount // 应用单人上限(即使是最后一个也要限制,超出部分由过期结算退回) if packet.MaxGrabAmount.GreaterThan(decimal.Zero) && amount.GreaterThan(packet.MaxGrabAmount) { amount = packet.MaxGrabAmount } return amount } // 普通红包:平均分配 if packet.PacketType == 1 { amount = packet.RemainAmount.Div(decimal.NewFromInt(int64(packet.RemainCount))) } else if packet.PacketType == 2 { // 手气红包:二倍均值算法 avg := packet.RemainAmount.Div(decimal.NewFromInt(int64(packet.RemainCount))) // 最大值 = 平均值 * 2 maxAmount := avg.Mul(decimal.NewFromInt(2)) // 最小值:0.01 minAmount := decimal.NewFromFloat(0.01) // 随机金额:minAmount ~ maxAmount amount = s.randomDecimal(minAmount, maxAmount) // 确保不超过剩余金额 if amount.GreaterThan(packet.RemainAmount) { amount = packet.RemainAmount } } else { // 默认平均分配 amount = packet.RemainAmount.Div(decimal.NewFromInt(int64(packet.RemainCount))) } // 应用单人上限 if packet.MaxGrabAmount.GreaterThan(decimal.Zero) && amount.GreaterThan(packet.MaxGrabAmount) { amount = packet.MaxGrabAmount } return amount } // randomDecimal 生成随机金额 func (s *RedPacketService) randomDecimal(min, max decimal.Decimal) decimal.Decimal { // 计算范围 diff := max.Sub(min) // 生成随机数 0-1 randomBig, _ := rand.Int(rand.Reader, big.NewInt(10000)) randomFloat := decimal.NewFromBigInt(randomBig, -4) // 0.0000 - 0.9999 // 计算随机金额 amount := min.Add(diff.Mul(randomFloat)) // 保留2位小数 return amount.Round(2) } // CalculateBestLuck 计算手气最佳 func (s *RedPacketService) CalculateBestLuck(packetId int64) { // 查询所有记录 var records []entity.TgRedPacketRecord s.DB().Where("packet_id = ?", packetId).Order("amount DESC").Find(&records) if len(records) == 0 { return } // 标记手气最佳(金额最大的) bestRecord := records[0] s.DB().Model(&entity.TgRedPacketRecord{}). Where("id = ?", bestRecord.Id). Update("is_best", 1) } // GetRedPacketDetail 获取红包详情 func (s *RedPacketService) GetRedPacketDetail(packetNo string) (*entity.TgRedPacket, []entity.TgRedPacketRecord, error) { var packet entity.TgRedPacket if err := s.DB().Where("packet_no = ?", packetNo).First(&packet).Error; err != nil { return nil, nil, err } var records []entity.TgRedPacketRecord s.DB().Where("packet_id = ?", packet.Id).Order("grabbed_at ASC").Find(&records) return &packet, records, nil } // GetUserRedPacketRecords 获取用户的红包记录 func (s *RedPacketService) GetUserRedPacketRecords(userId int64, page, pageSize int) ([]entity.TgRedPacketRecord, int64, error) { var records []entity.TgRedPacketRecord var total int64 db := s.DB().Model(&entity.TgRedPacketRecord{}).Where("user_id = ?", userId) db.Count(&total) offset := (page - 1) * pageSize if err := db.Order("grabbed_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { return nil, 0, err } return records, total, nil } // ExpiredPacketInfo 过期红包信息(用于 tasks 层编辑 Telegram 消息) type ExpiredPacketInfo struct { ChatID int64 MessageID int Text string } // ProcessExpiredPackets 处理所有已过期但状态仍为进行中的红包 // 返回需要编辑 Telegram 消息的红包信息列表(由 tasks 层调用 telegram 包避免循环依赖) func (s *RedPacketService) ProcessExpiredPackets() []ExpiredPacketInfo { var packets []entity.TgRedPacket s.DB().Where("status = 1 AND expire_at < ?", time.Now()).Find(&packets) if len(packets) == 0 { return nil } core.Log.Infof("发现 %d 个过期红包,开始结算...", len(packets)) var editList []ExpiredPacketInfo for _, packet := range packets { info := s.settleExpiredPacket(&packet) if info != nil { editList = append(editList, *info) } } return editList } // settleExpiredPacket 结算单个过期红包 func (s *RedPacketService) settleExpiredPacket(packet *entity.TgRedPacket) *ExpiredPacketInfo { now := time.Now() err := s.DB().Transaction(func(tx *gorm.DB) error { // 1. 更新红包状态为已过期 if err := tx.Model(packet).Updates(map[string]interface{}{ "status": 3, // 已过期 "completed_at": &now, }).Error; err != nil { return fmt.Errorf("更新状态失败: %w", err) } // 2. 退还未领取金额给发送者(管理员发的 userId=0 不退) if packet.UserId > 0 && packet.RemainAmount.GreaterThan(decimal.Zero) { bs := constant.BsById(constant.BsRedPacketRefund) bs.Desc = fmt.Sprintf("红包过期退回: %s %s, 红包: %s", packet.RemainAmount.String(), packet.Symbol, packet.PacketNo) bs.ContextName = "红包编号" bs.ContextValue = packet.PacketNo // balance += remainAmount (退回可用余额), frozen -= remainAmount (解冻) if err := s.GenBillAndActionAsset(tx, packet.UserId, packet.Symbol, packet.RemainAmount, packet.RemainAmount.Neg(), bs); err != nil { return fmt.Errorf("退还余额失败: %w", err) } } return nil }) if err != nil { core.Log.Errorf("结算过期红包失败 [%s]: %v", packet.PacketNo, err) return nil } // 3. 如果有人领取过,计算手气最佳 if packet.GrabbedCount > 0 { go s.CalculateBestLuck(packet.Id) } core.Log.Infof("红包过期结算完成 [%s]: 退回 %s %s, 已领 %d/%d", packet.PacketNo, packet.RemainAmount.String(), packet.Symbol, packet.GrabbedCount, packet.TotalCount) // 4. 如果有 Telegram 消息ID,构建编辑文本返回给 tasks 层 if packet.MessageId > 0 { text := s.buildExpiredMessageText(packet) var chatID int64 fmt.Sscanf(packet.GroupId, "%d", &chatID) return &ExpiredPacketInfo{ ChatID: chatID, MessageID: int(packet.MessageId), Text: text, } } return nil } // buildExpiredMessageText 构建过期红包的 Telegram 消息文本 func (s *RedPacketService) buildExpiredMessageText(packet *entity.TgRedPacket) string { // 查询已领取记录 _, records, _ := s.GetRedPacketDetail(packet.PacketNo) lang := packet.Lang if lang == "" { lang = "vi" } m := i18n.GetLang(lang) packetTypeText := i18n.PacketTypeText(packet.PacketType, lang) editText := fmt.Sprintf("%s\n💰 %s\n\n%s: %.2f %s | %s: %d | %s", m.RedPacketTitle, packet.BlessingWords, m.TotalAmount, packet.TotalAmount.InexactFloat64(), packet.Symbol, m.Count, packet.TotalCount, packetTypeText) if len(records) > 0 { editText += fmt.Sprintf("\n\n"+m.GrabDetail, packet.GrabbedCount, packet.TotalCount) for _, r := range records { displayName := r.TelegramUsername if displayName != "" { displayName = "@" + displayName } else { displayName = fmt.Sprintf(m.UserPrefix, r.TelegramId) } editText += fmt.Sprintf("\n"+m.GrabRecord, displayName, r.Amount.InexactFloat64(), packet.Symbol) } } editText += "\n\n" + m.ExpiredMsg return editText }