| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- 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
- }
|