package wheel import ( "context" "crypto/rand" "fmt" "math/big" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" ) // Draw executes one wheel draw request for an authenticated user. func (s *Service) Draw(ctx context.Context, user AuthUser, req DrawRequest) (*DrawResponse, error) { sysOrigin := normalizeSysOrigin(user.SysOrigin) if sysOrigin == "" || user.UserID <= 0 { return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required") } if s.java == nil { return nil, NewAppError(http.StatusServiceUnavailable, "wheel_gateway_unavailable", "reward gateway is unavailable") } roomID, err := s.resolveCurrentRoomID(ctx, user) if err != nil { return nil, err } category := normalizeCategory(req.Category) if _, ok := categoryMeta(category); !ok { return nil, NewAppError(http.StatusBadRequest, "invalid_category", "category must be CLASSIC, LUXURY, or ADVANCED") } times, err := normalizeDrawTimes(req.Times) if err != nil { return nil, err } bundle, err := s.loadConfigBundle(ctx, sysOrigin, true) if err != nil { return nil, err } if bundle.Config == nil || !bundle.Config.Enabled { return nil, NewAppError(http.StatusNotFound, "wheel_not_available", "wheel is not enabled") } pool, rewards, err := selectDrawPool(bundle, category) if err != nil { return nil, err } if len(rewards) == 0 { return nil, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured") } paidGold, err := priceForTimes(pool, times) if err != nil { return nil, err } selected := make([]model.WheelRewardConfig, 0, times) for index := 0; index < times; index++ { reward, pickErr := pickReward(rewards) if pickErr != nil { return nil, pickErr } selected = append(selected, reward) } drawID, err := utils.NextID() if err != nil { return nil, err } drawNo := fmt.Sprintf("WHEEL%d", drawID) records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, category, times, paidGold, selected) if err != nil { return nil, err } if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, paidGold); err != nil { _ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error()) return nil, mapWalletError("wheel_wallet_deduct_failed", err) } for index := range records { record := &records[index] if err := s.grantReward(ctx, record); err != nil { _ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error()) return nil, NewAppError(http.StatusBadGateway, "wheel_reward_grant_failed", err.Error()) } record.Status = drawStatusSuccess record.UpdateTime = time.Now() if err := s.markRecordStatus(ctx, record.ID, drawStatusSuccess, ""); err != nil { return nil, err } } var balanceAfter int64 if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil { balanceAfter = balanceMap[user.UserID] } return buildDrawResponse(drawNo, user.UserID, sysOrigin, roomID, category, times, paidGold, balanceAfter, records), nil } func (s *Service) resolveCurrentRoomID(ctx context.Context, user AuthUser) (int64, error) { profile, err := s.java.GetRoomProfileByUserID(ctx, user.UserID) if err != nil { return 0, NewAppError(http.StatusBadGateway, "wheel_current_room_lookup_failed", err.Error()) } roomID := int64(profile.ID) if roomID <= 0 { return 0, NewAppError(http.StatusBadRequest, "wheel_current_room_required", "current room is required") } if profile.SysOrigin != "" && !strings.EqualFold(profile.SysOrigin, user.SysOrigin) { return 0, NewAppError(http.StatusBadRequest, "wheel_current_room_sys_origin_mismatch", "current room sysOrigin does not match user") } return roomID, nil } func selectDrawPool(bundle *configBundle, category string) (model.WheelPoolConfig, []model.WheelRewardConfig, error) { var pool model.WheelPoolConfig foundPool := false for _, item := range bundle.Categories { if normalizeCategory(item.Category) == category { pool = item foundPool = true break } } if !foundPool || !pool.Enabled { return model.WheelPoolConfig{}, nil, NewAppError(http.StatusNotFound, "wheel_category_not_available", "wheel category is not enabled") } rewards := make([]model.WheelRewardConfig, 0, len(bundle.Rewards)) for _, item := range bundle.Rewards { if normalizeCategory(item.Category) == category && item.Enabled { rewards = append(rewards, item) } } meta, _ := categoryMeta(category) if len(rewards) != meta.RewardCount { return model.WheelPoolConfig{}, nil, NewAppError(http.StatusNotFound, "wheel_rewards_incomplete", fmt.Sprintf("%s rewards must contain exactly %d enabled items", category, meta.RewardCount)) } for _, reward := range rewards { if err := validateDrawableReward(reward); err != nil { return model.WheelPoolConfig{}, nil, NewAppError(http.StatusInternalServerError, "wheel_reward_invalid", err.Error()) } } return pool, rewards, nil } func validateDrawableReward(reward model.WheelRewardConfig) error { switch normalizeRewardType(reward.RewardType) { case rewardTypeGold: if reward.GoldAmount <= 0 { return fmt.Errorf("gold reward amount is empty") } case rewardTypeResource: if reward.ResourceID == nil || *reward.ResourceID <= 0 { return fmt.Errorf("resource reward is empty") } if strings.TrimSpace(reward.ResourceType) == "" { return fmt.Errorf("resource reward type is empty") } if reward.DurationDays <= 0 { return fmt.Errorf("resource reward duration is empty") } default: return fmt.Errorf("unsupported reward type %s", reward.RewardType) } return nil } func priceForTimes(configRow model.WheelPoolConfig, times int) (int64, error) { switch times { case 1: return configRow.PriceOneGold, nil case 10: return configRow.PriceTenGold, nil case 50: return configRow.PriceFiftyGold, nil default: return 0, NewAppError(http.StatusBadRequest, "invalid_draw_times", "times must be 1, 10, or 50") } } func pickReward(rewards []model.WheelRewardConfig) (model.WheelRewardConfig, error) { total := 0 for _, reward := range rewards { if reward.Enabled && reward.Probability > 0 { total += reward.Probability } } if total <= 0 { return model.WheelRewardConfig{}, NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured") } n, err := rand.Int(rand.Reader, big.NewInt(int64(total))) if err != nil { return model.WheelRewardConfig{}, err } target := int(n.Int64()) + 1 accumulated := 0 for _, reward := range rewards { if !reward.Enabled || reward.Probability <= 0 { continue } accumulated += reward.Probability if target <= accumulated { return reward, nil } } return rewards[len(rewards)-1], nil } func (s *Service) createPendingRecords( ctx context.Context, drawNo string, userID int64, sysOrigin string, category string, times int, paidGold int64, rewards []model.WheelRewardConfig, ) ([]model.WheelDrawRecord, error) { now := time.Now() records := make([]model.WheelDrawRecord, 0, len(rewards)) for index, reward := range rewards { nextID, err := utils.NextID() if err != nil { return nil, err } records = append(records, model.WheelDrawRecord{ ID: nextID, DrawNo: drawNo, EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1), SysOrigin: sysOrigin, Category: category, UserID: userID, DrawTimes: times, PaidGold: paidGold, RewardType: normalizeRewardType(reward.RewardType), ResourceID: reward.ResourceID, ResourceType: strings.ToUpper(strings.TrimSpace(reward.ResourceType)), ResourceName: reward.ResourceName, ResourceURL: reward.ResourceURL, CoverURL: reward.CoverURL, DurationDays: reward.DurationDays, DisplayGoldAmount: reward.DisplayGoldAmount, GoldAmount: reward.GoldAmount, Probability: reward.Probability, Status: drawStatusPending, CreateTime: now, UpdateTime: now, }) } if len(records) == 0 { return records, nil } if err := s.db.WithContext(ctx).Create(&records).Error; err != nil { return nil, err } return records, nil } func (s *Service) deductDrawPrice(ctx context.Context, drawNo string, userID int64, sysOrigin string, paidGold int64) error { if paidGold <= 0 { return nil } return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: walletReceiptExpenditure, UserID: userID, SysOrigin: sysOrigin, EventID: fmt.Sprintf("%s:PAY", drawNo), Remark: fmt.Sprintf("wheel draw %s", drawNo), Amount: integration.NewPennyAmountPayloadFromDollar(paidGold), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOrigin, CustomizeOriginDesc: walletOriginDesc, }) } func (s *Service) grantReward(ctx context.Context, record *model.WheelDrawRecord) error { switch normalizeRewardType(record.RewardType) { case rewardTypeGold: if record.GoldAmount <= 0 { return fmt.Errorf("gold reward amount is empty") } return s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: walletReceiptIncome, UserID: record.UserID, SysOrigin: record.SysOrigin, EventID: record.EventID, Remark: fmt.Sprintf("wheel reward %s", record.DrawNo), Amount: integration.NewPennyAmountPayloadFromDollar(record.GoldAmount), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOrigin, CustomizeOriginDesc: walletOriginDesc, }) case rewardTypeResource: return s.grantResourceReward(ctx, record) default: return fmt.Errorf("unsupported reward type %s", record.RewardType) } } func (s *Service) grantResourceReward(ctx context.Context, record *model.WheelDrawRecord) error { if record.ResourceID == nil || *record.ResourceID <= 0 { return fmt.Errorf("resource reward is empty") } days := record.DurationDays if days <= 0 { return fmt.Errorf("resource reward duration is empty") } resourceID := *record.ResourceID resourceType := strings.ToUpper(strings.TrimSpace(record.ResourceType)) if resourceType == "" { return fmt.Errorf("resource reward type is empty") } if resourceType == resourceTypeBadge || resourceType == resourceTypeRoomBadge { if err := s.java.ActivateTemporaryBadge(ctx, record.UserID, resourceID, days); err != nil { return err } _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) return nil } useProps := resourceType != resourceTypeGift if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ AcceptUserID: record.UserID, PropsID: resourceID, Type: resourceType, Origin: walletOrigin, OriginDesc: walletOriginDesc, Days: days, UseProps: useProps, AllowGive: false, }); err != nil { return err } if useProps { if err := s.java.SwitchUseProps(ctx, record.UserID, resourceID); err != nil { return err } _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) } return nil } func (s *Service) markDrawRecords(ctx context.Context, drawNo string, status string, message string) error { return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). Where("draw_no = ?", drawNo). Updates(map[string]any{ "status": status, "error_message": trimErrorMessage(message), "update_time": time.Now(), }).Error } func (s *Service) markRecordStatus(ctx context.Context, id int64, status string, message string) error { return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "error_message": trimErrorMessage(message), "update_time": time.Now(), }).Error } func buildDrawResponse( drawNo string, userID int64, sysOrigin string, roomID int64, category string, times int, paidGold int64, balanceAfter int64, records []model.WheelDrawRecord, ) *DrawResponse { payloadRecords := make([]DrawRecordPayload, 0, len(records)) for _, record := range records { payloadRecords = append(payloadRecords, drawRecordPayload(record)) } rewards, rewardValue := aggregateRewards(records) return &DrawResponse{ DrawNo: drawNo, UserID: userID, SysOrigin: sysOrigin, RoomID: roomID, Category: category, DrawTimes: times, PaidGold: paidGold, RewardValue: rewardValue, Rewards: rewards, Records: payloadRecords, BalanceAfter: balanceAfter, } } func aggregateRewards(records []model.WheelDrawRecord) ([]DrawRewardPayload, int64) { type bucket struct { payload DrawRewardPayload } buckets := map[string]*bucket{} order := make([]string, 0, len(records)) var total int64 for _, record := range records { value := record.GoldAmount if record.RewardType != rewardTypeGold { value = record.DisplayGoldAmount } total += value key := fmt.Sprintf("%s:%d:%s:%d:%d", record.RewardType, int64PtrValue(record.ResourceID), record.ResourceType, record.GoldAmount, record.DisplayGoldAmount) if _, exists := buckets[key]; !exists { name := strings.TrimSpace(record.ResourceName) if name == "" && record.RewardType == rewardTypeGold { name = "金币" } buckets[key] = &bucket{payload: DrawRewardPayload{ RewardType: record.RewardType, ResourceID: resourceIDValue(record.ResourceID), ResourceType: record.ResourceType, ResourceName: record.ResourceName, ResourceURL: record.ResourceURL, CoverURL: record.CoverURL, DurationDays: record.DurationDays, DisplayGoldAmount: record.DisplayGoldAmount, GoldAmount: record.GoldAmount, Probability: record.Probability, Name: name, Value: value, }} order = append(order, key) } buckets[key].payload.Count++ } result := make([]DrawRewardPayload, 0, len(order)) for _, key := range order { result = append(result, buckets[key].payload) } return result, total } func int64PtrValue(value *int64) int64 { if value == nil { return 0 } return *value } func mapWalletError(code string, err error) *AppError { message := err.Error() normalized := strings.ToLower(message) if strings.Contains(normalized, "insufficient_balance") || strings.Contains(normalized, "balance not made") || strings.Contains(normalized, `"errorcode":5000`) { return NewAppError(http.StatusNotAcceptable, "wheel_insufficient_balance", "insufficient balance") } return NewAppError(http.StatusBadGateway, code, message) }