package voiceroomredpacket import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Claim 领取语音房红包。 func (s *Service) Claim(ctx context.Context, user AuthUser, req ClaimRequest) (*ClaimResponse, error) { packetNo := strings.TrimSpace(req.PacketNo) if packetNo == "" { return nil, NewAppError(http.StatusBadRequest, "missing_packet_no", "packetNo is required") } sysOrigin := normalizeSysOrigin(user.SysOrigin) lockKey := fmt.Sprintf("voice_room:red_packet:claim_lock:%s:%d", packetNo, user.UserID) 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_claim_in_progress", "red packet claim is in progress") } defer s.redis.Del(context.Background(), lockKey) packet, err := s.loadPacketByNo(ctx, packetNo) if err != nil { return nil, err } if packet == nil { return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") } if existing, err := s.loadUserClaim(ctx, packet.ID, user.UserID); err != nil { return nil, err } else if existing != nil { return s.resumeExistingClaim(ctx, *packet, *existing) } userRegion, err := s.resolveCachedUserRegion(ctx, sysOrigin, user.UserID) if err != nil { return nil, err } var claim model.VoiceRoomRedPacketClaim if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var locked model.VoiceRoomRedPacket err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("packet_no = ?", packetNo). First(&locked).Error if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") } if err != nil { return err } if err := validateClaimable(locked, userRegion); err != nil { return err } existing, err := loadUserClaimTx(tx, locked.ID, user.UserID) if err != nil { return err } if existing != nil { claim = *existing return nil } var slice model.VoiceRoomRedPacketSlice err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("packet_id = ? AND status = ?", locked.ID, sliceStatusUnclaimed). Order("sort_no asc"). First(&slice).Error if errors.Is(err, gorm.ErrRecordNotFound) { _ = tx.Model(&model.VoiceRoomRedPacket{}). Where("id = ?", locked.ID). Updates(map[string]any{"status": packetStatusFinished, "update_time": time.Now()}).Error return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished") } if err != nil { return err } claimID, err := utils.NextID() if err != nil { return err } now := time.Now() claim = model.VoiceRoomRedPacketClaim{ ID: claimID, PacketID: locked.ID, PacketNo: locked.PacketNo, SliceID: slice.ID, SysOrigin: locked.SysOrigin, RoomID: locked.RoomID, UserID: user.UserID, UserRegionCode: userRegion, Amount: slice.Amount, WalletEventID: walletEventID(walletOriginClaim, locked.SysOrigin, locked.PacketNo, claimID), WalletStatus: walletStatusPending, ClaimTime: now, CreateTime: now, UpdateTime: now, } if err := tx.Create(&claim).Error; err != nil { return err } if err := tx.Model(&model.VoiceRoomRedPacketSlice{}). Where("id = ? AND status = ?", slice.ID, sliceStatusUnclaimed). Updates(map[string]any{ "status": sliceStatusClaiming, "claim_user_id": user.UserID, "claim_record_id": claim.ID, "update_time": now, }).Error; err != nil { return err } remainAmount := locked.RemainAmount - slice.Amount remainCount := locked.RemainCount - 1 if remainAmount < 0 { remainAmount = 0 } if remainCount < 0 { remainCount = 0 } return tx.Model(&model.VoiceRoomRedPacket{}). Where("id = ?", locked.ID). Updates(map[string]any{ "remain_amount": remainAmount, "remain_count": remainCount, "update_time": now, }).Error }); err != nil { return nil, err } return s.completeClaimWallet(ctx, claim) } func (s *Service) resumeExistingClaim(ctx context.Context, packet model.VoiceRoomRedPacket, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) { if claim.WalletStatus == walletStatusSuccess { return s.buildClaimResponse(ctx, claim) } if err := validateClaimableForRetry(packet); err != nil { return nil, err } return s.completeClaimWallet(ctx, claim) } func (s *Service) completeClaimWallet(ctx context.Context, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) { if claim.WalletStatus != walletStatusSuccess { err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: walletReceiptIncome, UserID: claim.UserID, SysOrigin: claim.SysOrigin, EventID: claim.WalletEventID, Remark: fmt.Sprintf("voice room red packet claim %s", claim.PacketNo), Amount: integration.NewPennyAmountPayloadFromDollar(claim.Amount), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOriginClaim, CustomizeOriginDesc: walletOriginClaimDesc, }) if err != nil { _ = s.db.WithContext(context.Background()).Model(&model.VoiceRoomRedPacketClaim{}). Where("id = ?", claim.ID). Updates(map[string]any{ "wallet_status": walletStatusFailed, "error_message": trimErrorMessage(err.Error()), "update_time": time.Now(), }).Error return nil, mapWalletError("voice_room_red_packet_wallet_income_failed", err) } now := time.Now() if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Model(&model.VoiceRoomRedPacketClaim{}). Where("id = ?", claim.ID). Updates(map[string]any{ "wallet_status": walletStatusSuccess, "error_message": "", "update_time": now, }).Error; err != nil { return err } if err := tx.Model(&model.VoiceRoomRedPacketSlice{}). Where("id = ?", claim.SliceID). Updates(map[string]any{ "status": sliceStatusClaimed, "update_time": now, }).Error; err != nil { return err } return tx.Model(&model.VoiceRoomRedPacket{}). Where("id = ? AND remain_count = 0 AND status = ?", claim.PacketID, packetStatusActive). Updates(map[string]any{ "status": packetStatusFinished, "update_time": now, }).Error }); err != nil { return nil, err } claim.WalletStatus = walletStatusSuccess claim.UpdateTime = now } return s.buildClaimResponse(ctx, claim) } func validateClaimable(packet model.VoiceRoomRedPacket, userRegion string) error { if packet.Status != packetStatusActive { if packet.Status == packetStatusFinished { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished") } if packet.Status == packetStatusExpired || packet.Status == packetStatusRefunded || packet.Status == packetStatusRefundFailed { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired") } return NewAppError(http.StatusBadRequest, "voice_room_red_packet_unavailable", "red packet is unavailable") } now := time.Now() if now.Before(packet.ClaimStartTime) { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_not_started", "red packet is not started") } if !now.Before(packet.ExpireTime) { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired") } if !strings.EqualFold(strings.TrimSpace(packet.SenderRegionCode), strings.TrimSpace(userRegion)) { return NewAppError(http.StatusForbidden, "voice_room_region_not_match", "user region does not match") } if packet.RemainCount <= 0 || packet.RemainAmount <= 0 { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_finished", "red packet is finished") } return nil } func validateClaimableForRetry(packet model.VoiceRoomRedPacket) error { if packet.Status == packetStatusExpired || packet.Status == packetStatusRefunded || packet.Status == packetStatusRefundFailed { return NewAppError(http.StatusBadRequest, "voice_room_red_packet_expired", "red packet is expired") } return nil } func (s *Service) loadPacketByNo(ctx context.Context, packetNo string) (*model.VoiceRoomRedPacket, error) { var row model.VoiceRoomRedPacket err := s.db.WithContext(ctx).Where("packet_no = ?", packetNo).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func (s *Service) loadUserClaim(ctx context.Context, packetID, userID int64) (*model.VoiceRoomRedPacketClaim, error) { return loadUserClaimTx(s.db.WithContext(ctx), packetID, userID) } func loadUserClaimTx(tx *gorm.DB, packetID, userID int64) (*model.VoiceRoomRedPacketClaim, error) { var row model.VoiceRoomRedPacketClaim err := tx.Where("packet_id = ? AND user_id = ?", packetID, userID).First(&row).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } if err != nil { return nil, err } return &row, nil } func (s *Service) buildClaimResponse(ctx context.Context, claim model.VoiceRoomRedPacketClaim) (*ClaimResponse, error) { packet, err := s.loadPacketByNo(ctx, claim.PacketNo) if err != nil { return nil, err } if packet == nil { return nil, NewAppError(http.StatusNotFound, "voice_room_red_packet_not_found", "red packet not found") } return &ClaimResponse{ PacketNo: claim.PacketNo, Amount: claim.Amount, RemainCount: packet.RemainCount, TotalCount: packet.TotalCount, ClaimTime: formatTime(claim.ClaimTime), Status: claim.WalletStatus, }, nil }