package voiceroomrocket import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) var errRewardLocked = errors.New("reward record is locked") func (s *Service) createImmediateRewardRecords(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, top1UserID *int64, igniteUserID int64, now time.Time) error { rewards, err := s.loadRewardConfigs(ctx, launch.SysOrigin, launch.Level) if err != nil { return err } for _, reward := range rewards { scene := strings.ToUpper(strings.TrimSpace(reward.RewardScene)) switch scene { case rewardSceneTop1: if top1UserID == nil || *top1UserID <= 0 { continue } if _, err := s.createRewardRecord(ctx, tx, launch, reward, *top1UserID, now); err != nil { return err } case rewardSceneIgnite: if igniteUserID <= 0 { continue } if _, err := s.createRewardRecord(ctx, tx, launch, reward, igniteUserID, now); err != nil { return err } } } return nil } func (s *Service) createRewardRecord(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, reward model.VoiceRoomRocketRewardConfig, userID int64, now time.Time) (model.VoiceRoomRocketRewardRecord, error) { if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) { return model.VoiceRoomRocketRewardRecord{}, nil } id, err := utils.NextID() if err != nil { return model.VoiceRoomRocketRewardRecord{}, err } eventID := rewardGrantEventID(launch.LaunchNo, reward.RewardScene, userID, reward.ID) record := model.VoiceRoomRocketRewardRecord{ ID: id, LaunchID: launch.ID, LaunchNo: launch.LaunchNo, SysOrigin: launch.SysOrigin, RoomID: launch.RoomID, DayKey: launch.DayKey, RoundNo: launch.RoundNo, Level: launch.Level, UserID: userID, RewardScene: reward.RewardScene, RewardConfigID: reward.ID, RewardType: reward.RewardType, RewardItemID: reward.RewardItemID, RewardName: reward.RewardName, RewardCover: reward.RewardCover, RewardAmount: reward.RewardAmount, ExpireDays: reward.ExpireDays, GrantEventID: eventID, GrantStatus: rewardStatusPending, PopupStatus: popupStatusUnread, CreateTime: now, UpdateTime: now, } if err := tx.WithContext(ctx).Create(&record).Error; err != nil && !isDuplicateKeyErr(err) { return model.VoiceRoomRocketRewardRecord{}, err } if err != nil && isDuplicateKeyErr(err) { _ = tx.WithContext(ctx).Where("grant_event_id = ?", eventID).First(&record).Error } return record, nil } // RetryReward 尝试补发一条失败或待发奖励。当前先接金币,其他权益保留 PENDING 由后续 gateway 适配。 func (s *Service) RetryReward(ctx context.Context, rewardRecordID int64) error { if rewardRecordID <= 0 { return nil } var row model.VoiceRoomRocketRewardRecord if err := s.repo.DB.WithContext(ctx).Where("id = ?", rewardRecordID).First(&row).Error; err != nil { return err } return s.processRewardRecord(ctx, row) } // ProcessPendingRewards 自动扫描 PENDING 奖励记录并发放。 func (s *Service) ProcessPendingRewards(ctx context.Context, limit int) (int, error) { if limit <= 0 { limit = defaultWorkerBatchSize } var rows []model.VoiceRoomRocketRewardRecord if err := s.repo.DB.WithContext(ctx). Where("grant_status = ?", rewardStatusPending). Order("create_time ASC, id ASC"). Limit(limit). Find(&rows).Error; err != nil { return 0, err } processed := 0 for _, row := range rows { if err := s.processRewardRecord(ctx, row); err != nil { return processed, err } processed++ } return processed, nil } func (s *Service) processRewardRecord(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { if row.ID <= 0 { return nil } unlock, err := s.acquireRewardLock(ctx, row.ID) if err != nil { if errors.Is(err, errRewardLocked) { return nil } return err } if unlock != nil { defer unlock() } var latest model.VoiceRoomRocketRewardRecord if err := s.repo.DB.WithContext(ctx).Where("id = ?", row.ID).First(&latest).Error; err != nil { return err } if latest.GrantStatus == rewardStatusSuccess { return nil } return s.grantReward(ctx, latest) } func (s *Service) grantReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { var err error switch strings.ToUpper(strings.TrimSpace(row.RewardType)) { case rewardTypeGold: err = s.grantGoldReward(ctx, row) case rewardTypeMount, rewardTypeAvatarFrame, rewardTypeChatBubble, rewardTypeVIPCard, rewardTypeGift, rewardTypeBadge: err = s.grantPropsReward(ctx, row) default: err = NewAppError(http.StatusBadRequest, "unsupported_reward_type", "unsupported reward type: "+row.RewardType) } now := time.Now() if err != nil { _ = s.repo.DB.WithContext(context.Background()). Model(&model.VoiceRoomRocketRewardRecord{}). Where("id = ?", row.ID). Updates(map[string]any{ "grant_status": rewardStatusFailed, "error_message": truncateString(err.Error(), 1024), "update_time": now, }).Error return err } if err := s.repo.DB.WithContext(ctx). Model(&model.VoiceRoomRocketRewardRecord{}). Where("id = ?", row.ID). Updates(map[string]any{ "grant_status": rewardStatusSuccess, "error_message": "", "grant_time": now, "update_time": now, }).Error; err != nil { return err } _ = s.notifyRewardRecord(context.Background(), row) return nil } func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { if s.wallet == nil { return NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable") } exists, err := s.wallet.ExistsGoldEvent(ctx, row.GrantEventID) if err != nil { return err } if !exists { if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: "INCOME", UserID: row.UserID, SysOrigin: row.SysOrigin, EventID: row.GrantEventID, Remark: fmt.Sprintf("voice room rocket %s level %d", row.RewardScene, row.Level), Amount: integration.NewPennyAmountPayloadFromDollar(row.RewardAmount), CloseDelayAsset: true, OpUserType: "BACK", CustomizeOrigin: "VOICE_ROOM_ROCKET", CustomizeOriginDesc: "Voice Room Rocket Reward", }); err != nil { return err } } return nil } func (s *Service) grantPropsReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error { if s.rewardGateway == nil { return NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable") } if row.RewardItemID == nil || *row.RewardItemID <= 0 { return NewAppError(http.StatusBadRequest, "missing_reward_item", "rewardItemId is required for "+row.RewardType) } itemID := *row.RewardItemID rewardType := strings.ToUpper(strings.TrimSpace(row.RewardType)) days := rewardDays(row) switch rewardType { case rewardTypeBadge: return s.rewardGateway.ActivateTemporaryBadge(ctx, row.UserID, itemID, days) case rewardTypeVIPCard: if err := s.giveProps(ctx, row, itemID, propsTypeNobleVIP, days, true); err != nil { return err } if err := s.grantVIPAccessories(ctx, row, itemID, days); err != nil { return err } _ = s.rewardGateway.RemoveUserProfileCacheAll(ctx, row.UserID) return nil default: propsType, err := propsTypeForRewardType(rewardType) if err != nil { return err } useProps := rewardType != rewardTypeGift if err := s.giveProps(ctx, row, itemID, propsType, days, useProps); err != nil { return err } if useProps { _ = s.rewardGateway.RemoveUserProfileCacheAll(ctx, row.UserID) } return nil } } func (s *Service) giveProps(ctx context.Context, row model.VoiceRoomRocketRewardRecord, propsID int64, propsType string, days int, useProps bool) error { if err := s.rewardGateway.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ AcceptUserID: row.UserID, PropsID: propsID, Type: propsType, Origin: "VOICE_ROOM_ROCKET", OriginDesc: "Voice Room Rocket Reward", Days: days, UseProps: useProps, AllowGive: false, }); err != nil { return err } if useProps { if err := s.rewardGateway.SwitchUseProps(ctx, row.UserID, propsID); err != nil { return err } } return nil } func (s *Service) grantVIPAccessories(ctx context.Context, row model.VoiceRoomRocketRewardRecord, sourceID int64, days int) error { ability, err := s.rewardGateway.GetNobleVIPAbility(ctx, sourceID) if err != nil { return nil } grants := []struct { id int64 typ string }{ {int64(ability.CarID), propsTypeRide}, {int64(ability.AvatarFrameID), propsTypeAvatarFrame}, {int64(ability.ChatBubbleID), propsTypeChatBubble}, } for _, grant := range grants { if grant.id <= 0 { continue } if err := s.giveProps(ctx, row, grant.id, grant.typ, days, true); err != nil { return err } } return nil } func propsTypeForRewardType(rewardType string) (string, error) { switch strings.ToUpper(strings.TrimSpace(rewardType)) { case rewardTypeMount: return propsTypeRide, nil case rewardTypeAvatarFrame: return propsTypeAvatarFrame, nil case rewardTypeChatBubble: return propsTypeChatBubble, nil case rewardTypeGift: return propsTypeGift, nil default: return "", NewAppError(http.StatusBadRequest, "unsupported_reward_type", "unsupported reward type: "+rewardType) } } func rewardDays(row model.VoiceRoomRocketRewardRecord) int { if row.ExpireDays != nil && *row.ExpireDays > 0 { return *row.ExpireDays } if row.RewardAmount > 0 && strings.ToUpper(strings.TrimSpace(row.RewardType)) != rewardTypeGold { if row.RewardAmount > int64(^uint(0)>>1) { return 1 } return int(row.RewardAmount) } if strings.EqualFold(row.RewardType, rewardTypeVIPCard) { return 30 } return 7 } func (s *Service) acquireRewardLock(ctx context.Context, rewardRecordID int64) (func(), error) { if s.repo.Redis == nil { return nil, nil } key := "voice_room:rocket:reward_lock:" + strconv.FormatInt(rewardRecordID, 10) token := strconv.FormatInt(time.Now().UnixNano(), 10) ok, err := s.repo.Redis.SetNX(ctx, key, token, 30*time.Second).Result() if err != nil { return nil, err } if !ok { return nil, errRewardLocked } return func() { _ = s.releaseRoomLock(context.Background(), key, token) }, nil } func rewardGrantEventID(launchNo, scene string, userID int64, rewardConfigID int64) string { return fmt.Sprintf("VOICE_ROOM_ROCKET:%s:%s:%d:%d", launchNo, strings.ToUpper(strings.TrimSpace(scene)), userID, rewardConfigID) }