package voiceroomredpacket import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "github.com/redis/go-redis/v9" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Send 创建语音房红包并完成发送人扣款。 func (s *Service) Send(ctx context.Context, user AuthUser, req SendRequest) (*SendResponse, error) { req.RequestID = strings.TrimSpace(req.RequestID) req.PacketMode = normalizePacketMode(req.PacketMode) sysOrigin := normalizeSysOrigin(user.SysOrigin) roomID := req.RoomID.Int64() if req.RequestID == "" { return nil, NewAppError(http.StatusBadRequest, "missing_request_id", "requestId is required") } if roomID <= 0 { return nil, NewAppError(http.StatusBadRequest, "missing_room_id", "roomId is required") } if req.PacketMode != packetModeImmediate && req.PacketMode != packetModeDelayed { return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_mode_invalid", "packetMode is invalid") } if existing, err := s.loadPacketByRequestID(ctx, req.RequestID); err != nil { return nil, err } else if existing != nil { return s.buildSendResponse(ctx, *existing) } lockKey := fmt.Sprintf("voice_room:red_packet:send_lock:%s:%s", sysOrigin, req.RequestID) lockOK, err := s.redis.SetNX(ctx, lockKey, "1", 15*time.Second).Result() if err != nil { return nil, NewAppError(http.StatusServiceUnavailable, "voice_room_red_packet_lock_failed", err.Error()) } if !lockOK { return nil, NewAppError(http.StatusConflict, "voice_room_red_packet_in_progress", "red packet send is in progress") } defer s.redis.Del(context.Background(), lockKey) if existing, err := s.loadPacketByRequestID(ctx, req.RequestID); err != nil { return nil, err } else if existing != nil { return s.buildSendResponse(ctx, *existing) } cfg, err := s.loadConfig(ctx, sysOrigin) if err != nil { return nil, err } if !cfg.Enabled { return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_disabled", "voice room red packet is disabled") } if !containsInt64(cfg.AmountOptions, req.TotalAmount) { return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_amount_invalid", "totalAmount is invalid") } if !containsInt(cfg.CountOptions, req.TotalCount) { return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_count_invalid", "totalCount is invalid") } if err := s.ensureUserInRoom(ctx, sysOrigin, user.UserID, roomID); err != nil { return nil, err } regionCode, err := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID) if err != nil { return nil, err } var packet model.VoiceRoomRedPacket var day string if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if existing, err := loadPacketByRequestIDTx(tx, req.RequestID); err != nil { return err } else if existing != nil { packet = *existing return nil } location := resolveLocation(cfg.Timezone) now := time.Now() localNow := now.In(location) day = dayKey(localNow) counter, err := s.incrementDailyCounterTx(tx, sysOrigin, user.UserID, day, cfg.DailySendLimit, now) if err != nil { return err } if counter.SendCount > cfg.DailySendLimit { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_daily_limit_exceeded", "daily send limit exceeded") } packetID, err := utils.NextID() if err != nil { return err } packetNo := buildPacketNo(localNow, packetID) claimStart := now if req.PacketMode == packetModeDelayed { claimStart = now.Add(time.Duration(cfg.DelaySeconds) * time.Second) } expireAt := now.Add(time.Duration(cfg.RefundAfterSeconds) * time.Second) packet = model.VoiceRoomRedPacket{ ID: packetID, PacketNo: packetNo, RequestID: req.RequestID, SysOrigin: sysOrigin, RoomID: roomID, SenderUserID: user.UserID, SenderRegionCode: regionCode, PacketMode: req.PacketMode, AmountMode: amountModeRandom, TotalAmount: req.TotalAmount, TotalCount: req.TotalCount, RemainAmount: req.TotalAmount, RemainCount: req.TotalCount, ClaimStartTime: claimStart, ExpireTime: expireAt, Status: packetStatusInit, SendWalletEventID: walletEventID(walletOriginSend, sysOrigin, packetNo, 0), RefundStatus: refundStatusNone, CreateTime: now, UpdateTime: now, } if err := tx.Create(&packet).Error; err != nil { return err } amounts, err := splitRandomAmounts(req.TotalAmount, req.TotalCount, packetID) if err != nil { return err } if sumAmounts(amounts) != req.TotalAmount { return NewAppError(http.StatusInternalServerError, "voice_room_red_packet_amount_split_invalid", "split amount total mismatch") } slices := make([]model.VoiceRoomRedPacketSlice, 0, len(amounts)) for index, amount := range amounts { sliceID, err := utils.NextID() if err != nil { return err } slices = append(slices, model.VoiceRoomRedPacketSlice{ ID: sliceID, PacketID: packet.ID, PacketNo: packet.PacketNo, SortNo: index + 1, Amount: amount, Status: sliceStatusUnclaimed, CreateTime: now, UpdateTime: now, }) } return tx.Create(&slices).Error }); err != nil { return nil, err } if packet.Status != packetStatusInit { return s.buildSendResponse(ctx, packet) } if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: walletReceiptExpenditure, UserID: user.UserID, SysOrigin: sysOrigin, EventID: packet.SendWalletEventID, Remark: fmt.Sprintf("voice room red packet %s room=%d", packet.PacketNo, packet.RoomID), Amount: integration.NewPennyAmountPayloadFromDollar(packet.TotalAmount), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOriginSend, CustomizeOriginDesc: walletOriginSendDesc, }); err != nil { _ = s.markSendFailed(context.Background(), packet.ID, sysOrigin, user.UserID, day, err.Error()) return nil, mapWalletError("voice_room_red_packet_wallet_deduct_failed", err) } now := time.Now() if err := s.db.WithContext(ctx).Model(&model.VoiceRoomRedPacket{}). Where("id = ? AND status = ?", packet.ID, packetStatusInit). Updates(map[string]any{ "status": packetStatusActive, "refund_status": refundStatusPending, "error_message": "", "update_time": now, }).Error; err != nil { return nil, err } packet.Status = packetStatusActive packet.RefundStatus = refundStatusPending packet.UpdateTime = now _ = s.redis.ZAdd(ctx, expireZSetKey(s.cfg), redis.Z{ Score: float64(packet.ExpireTime.Unix()), Member: packet.PacketNo, }).Err() if err := s.notifyCreated(ctx, packet); err != nil { logNotifyFailure(packet.PacketNo, err) _ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacket{}). Where("id = ?", packet.ID). Update("error_message", trimErrorMessage(err.Error())).Error } return s.buildSendResponse(ctx, packet) } func (s *Service) loadPacketByRequestID(ctx context.Context, requestID string) (*model.VoiceRoomRedPacket, error) { return loadPacketByRequestIDTx(s.db.WithContext(ctx), requestID) } func loadPacketByRequestIDTx(tx *gorm.DB, requestID string) (*model.VoiceRoomRedPacket, error) { var row model.VoiceRoomRedPacket err := tx.Where("request_id = ?", requestID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func (s *Service) incrementDailyCounterTx(tx *gorm.DB, sysOrigin string, userID int64, day string, limit int, now time.Time) (*model.VoiceRoomRedPacketDailyCounter, error) { var row model.VoiceRoomRedPacketDailyCounter err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("sys_origin = ? AND user_id = ? AND day_key = ?", sysOrigin, userID, day). First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { id, idErr := utils.NextID() if idErr != nil { return nil, idErr } row = model.VoiceRoomRedPacketDailyCounter{ ID: id, SysOrigin: sysOrigin, UserID: userID, DayKey: day, SendCount: 1, CreateTime: now, UpdateTime: now, } if limit > 0 && row.SendCount > limit { return &row, nil } if err := tx.Create(&row).Error; err != nil { return nil, err } return &row, nil } if err != nil { return nil, err } row.SendCount++ row.UpdateTime = now if limit > 0 && row.SendCount > limit { return &row, nil } if err := tx.Save(&row).Error; err != nil { return nil, err } return &row, nil } func (s *Service) markSendFailed(ctx context.Context, packetID int64, sysOrigin string, userID int64, day string, message string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.VoiceRoomRedPacket{}). Where("id = ?", packetID). Updates(map[string]any{ "status": packetStatusFailed, "error_message": trimErrorMessage(message), "update_time": time.Now(), }).Error; err != nil { return err } if day != "" { if err := tx.Model(&model.VoiceRoomRedPacketDailyCounter{}). Where("sys_origin = ? AND user_id = ? AND day_key = ? AND send_count > 0", sysOrigin, userID, day). Update("send_count", gorm.Expr("send_count - 1")).Error; err != nil { return err } } return nil }) } func (s *Service) buildSendResponse(ctx context.Context, packet model.VoiceRoomRedPacket) (*SendResponse, error) { if packet.Status == packetStatusFailed { return nil, NewAppError(http.StatusBadRequest, "voice_room_red_packet_send_failed", packet.ErrorMessage) } balanceAfter := int64(0) if s.wallet != nil { balances, err := s.wallet.MapGoldBalance(ctx, []int64{packet.SenderUserID}) if err != nil { return nil, NewAppError(http.StatusBadGateway, "voice_room_red_packet_wallet_balance_failed", err.Error()) } balanceAfter = balances[packet.SenderUserID] } return &SendResponse{ PacketNo: packet.PacketNo, RoomID: packet.RoomID, PacketMode: packet.PacketMode, TotalAmount: packet.TotalAmount, TotalCount: packet.TotalCount, RemainAmount: packet.RemainAmount, RemainCount: packet.RemainCount, ClaimStartTime: formatTime(packet.ClaimStartTime), ExpireTime: formatTime(packet.ExpireTime), Status: packet.Status, BalanceAfter: balanceAfter, }, nil } func buildPacketNo(now time.Time, id int64) string { return fmt.Sprintf("VRP%s%d", now.Format("20060102150405"), id%100000000) } func expireZSetKey(cfg config.Config) string { if value := strings.TrimSpace(cfg.VoiceRoomRedPacket.ExpireZSetKey); value != "" { return value } return defaultExpireZSetKey }