package wheel import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/service/idempotency" "chatapp3-golang/internal/service/prizepool" "chatapp3-golang/internal/utils" "gorm.io/gorm" ) // 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") } 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 } idempotencyRow, replayed, err := s.beginDrawIdempotency(ctx, sysOrigin, user.UserID, category, times, drawRequestIdempotencyKey(req)) if err != nil || replayed != nil { return replayed, err } failIdempotency := func(drawNo string, drawErr error) (*DrawResponse, error) { if idempotencyRow != nil { _ = idempotency.NewStore(s.db).MarkFailed(ctx, idempotencyRow.ID, drawNo, drawErr) } return nil, drawErr } roomID, err := s.resolveCurrentRoomID(ctx, user) if err != nil { return failIdempotency("", err) } drawID, err := utils.NextID() if err != nil { return failIdempotency("", err) } drawNo := fmt.Sprintf("WHEEL%d", drawID) var paidGold int64 var records []model.WheelDrawRecord if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { bundle, loadErr := s.loadConfigBundleTx(ctx, tx, sysOrigin, true, true) if loadErr != nil { return loadErr } if bundle.Config == nil || !bundle.Config.Enabled { return NewAppError(http.StatusNotFound, "wheel_not_available", "wheel is not enabled") } pool, rewards, selectErr := selectDrawPool(bundle, category) if selectErr != nil { return selectErr } if len(rewards) == 0 { return NewAppError(http.StatusNotFound, "wheel_rewards_empty", "wheel rewards are not configured") } price, priceErr := priceForTimes(pool, times) if priceErr != nil { return priceErr } created, createErr := s.createReservedPendingRecords(ctx, tx, drawNo, user.UserID, sysOrigin, category, times, price, rewards) if createErr != nil { return createErr } paidGold = price records = created return nil }); err != nil { return failIdempotency(drawNo, err) } if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, paidGold); err != nil { _ = s.releaseDrawReservations(ctx, records, drawStatusPayFailed, err.Error()) return failIdempotency(drawNo, mapWalletError("wheel_wallet_deduct_failed", err)) } grantFailed := false for index := range records { if err := s.ProcessDrawRecord(ctx, records[index].ID); err != nil { grantFailed = true } } records, _ = s.loadDrawRecords(ctx, drawNo) var balanceAfter int64 if balanceMap, balanceErr := s.java.MapGoldBalance(ctx, []int64{user.UserID}); balanceErr == nil { balanceAfter = balanceMap[user.UserID] } resp := buildDrawResponse(drawNo, user.UserID, sysOrigin, roomID, category, times, paidGold, balanceAfter, records) if grantFailed { resp.GrantFailed = true resp.Message = "some rewards are waiting for retry" } if idempotencyRow != nil { _ = idempotency.NewStore(s.db).MarkSucceeded(ctx, idempotencyRow.ID, drawNo, resp) } return resp, nil } func drawRequestIdempotencyKey(req DrawRequest) string { for _, value := range []string{req.IdempotencyKey, req.IdempotencyKeySnake, req.RequestID, req.RequestIDSnake, req.Nonce} { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func (s *Service) beginDrawIdempotency(ctx context.Context, sysOrigin string, userID int64, category string, times int, key string) (*model.ActivityIdempotency, *DrawResponse, error) { key = strings.TrimSpace(key) if key == "" { return nil, nil, nil } result, err := idempotency.NewStore(s.db).Begin(ctx, idempotency.BeginRequest{ Activity: "WHEEL", SysOrigin: sysOrigin, UserID: userID, IdempotencyKey: key, RequestHash: idempotency.Hash("WHEEL", category, times), }) if errors.Is(err, idempotency.ErrKeyTooLong) { return nil, nil, NewAppError(http.StatusBadRequest, "idempotency_key_too_long", "idempotencyKey must be 128 characters or less") } if errors.Is(err, idempotency.ErrRequestMismatch) { return nil, nil, NewAppError(http.StatusConflict, "idempotency_key_conflict", "idempotencyKey was already used for a different draw request") } if err != nil { return nil, nil, err } if result.Created { return &result.Record, nil, nil } switch result.Record.Status { case idempotency.StatusSucceeded: var resp DrawResponse if err := idempotency.DecodeResponse(result.Record, &resp); err != nil { return nil, nil, NewAppError(http.StatusConflict, "idempotency_response_unavailable", "idempotency response is unavailable") } return nil, &resp, nil case idempotency.StatusFailed: message := strings.TrimSpace(result.Record.ErrorMessage) if message == "" { message = "draw request failed" } return nil, nil, NewAppError(http.StatusConflict, "idempotency_request_failed", message) default: return nil, nil, NewAppError(http.StatusConflict, "idempotency_request_processing", "draw request is still processing") } } 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 (s *Service) createReservedPendingRecords( ctx context.Context, tx *gorm.DB, 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, times) for index := 0; index < times; index++ { rewardIndex, reward, err := s.pickAvailableReward(ctx, tx, rewards, userID) if err != nil { return nil, err } if err := reserveRewardIssue(ctx, tx, reward); err != nil { return nil, err } rewards[rewardIndex].IssuedCount++ nextID, err := utils.NextID() if err != nil { return nil, err } record := 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, RewardConfigID: reward.ID, 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 err := tx.WithContext(ctx).Create(&record).Error; err != nil { return nil, err } records = append(records, record) } return records, nil } func (s *Service) pickAvailableReward(ctx context.Context, tx *gorm.DB, rewards []model.WheelRewardConfig, userID int64) (int, model.WheelRewardConfig, error) { picked, err := prizepool.PickAvailable(ctx, wheelPrizePoolItems(rewards), userID, prizepool.UserIssueCounterFunc(func(ctx context.Context, rewardConfigID int64, userID int64) (int64, error) { return countUserRewardIssues(ctx, tx, rewardConfigID, userID) })) if errors.Is(err, prizepool.ErrNoAvailableItem) { return 0, model.WheelRewardConfig{}, NewAppError(http.StatusConflict, "wheel_rewards_exhausted", "wheel rewards are exhausted") } if err != nil { return 0, model.WheelRewardConfig{}, err } if picked.Index < 0 || picked.Index >= len(rewards) { return 0, model.WheelRewardConfig{}, NewAppError(http.StatusConflict, "wheel_rewards_exhausted", "wheel rewards are exhausted") } return picked.Index, rewards[picked.Index], nil } func wheelPrizePoolItems(rewards []model.WheelRewardConfig) []prizepool.Item { items := make([]prizepool.Item, 0, len(rewards)) for _, reward := range rewards { items = append(items, prizepool.Item{ ID: reward.ID, Enabled: reward.Enabled, Weight: reward.Probability, TotalLimit: reward.TotalLimit, UserLimit: reward.UserLimit, IssuedCount: reward.IssuedCount, }) } return items } func countUserRewardIssues(ctx context.Context, tx *gorm.DB, rewardConfigID int64, userID int64) (int64, error) { var count int64 err := tx.WithContext(ctx).Model(&model.WheelDrawRecord{}). Where("reward_config_id = ? AND user_id = ? AND status IN ?", rewardConfigID, userID, capOccupyingStatuses()). Count(&count).Error return count, err } func reserveRewardIssue(ctx context.Context, tx *gorm.DB, reward model.WheelRewardConfig) error { query := tx.WithContext(ctx).Model(&model.WheelRewardConfig{}).Where("id = ?", reward.ID) if reward.TotalLimit > 0 { query = query.Where("issued_count < total_limit") } result := query.UpdateColumn("issued_count", gorm.Expr("issued_count + ?", 1)) if result.Error != nil { return result.Error } if result.RowsAffected == 0 { return NewAppError(http.StatusConflict, "wheel_reward_exhausted", "wheel reward is exhausted") } return nil } func capOccupyingStatuses() []string { return []string{drawStatusPending, drawStatusGranting, drawStatusSuccess, drawStatusFailed} } 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, }) } // RetryDrawRecord retries one paid wheel reward record. PAY_FAILED records are not retried // because the user was not charged and the prize reservation has already been released. func (s *Service) RetryDrawRecord(ctx context.Context, recordID int64) error { if recordID <= 0 { return nil } return s.ProcessDrawRecord(ctx, recordID) } // ProcessFailedRecords retries failed paid rewards in creation order. func (s *Service) ProcessFailedRecords(ctx context.Context, limit int) (int, error) { if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } var rows []model.WheelDrawRecord if err := s.db.WithContext(ctx). Where("status = ?", drawStatusFailed). 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.ProcessDrawRecord(ctx, row.ID); err != nil { return processed, err } processed++ } return processed, nil } // ProcessDrawRecord grants one paid reward record and keeps failures retryable. func (s *Service) ProcessDrawRecord(ctx context.Context, recordID int64) error { var record model.WheelDrawRecord shouldGrant := false if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := withWriteLock(tx).Where("id = ?", recordID).First(&record).Error; err != nil { return err } switch record.Status { case drawStatusSuccess: return nil case drawStatusPending, drawStatusFailed: shouldGrant = true now := time.Now() if err := tx.Model(&model.WheelDrawRecord{}). Where("id = ? AND status IN ?", record.ID, []string{drawStatusPending, drawStatusFailed}). Updates(map[string]any{ "status": drawStatusGranting, "retry_count": gorm.Expr("retry_count + ?", 1), "update_time": now, }).Error; err != nil { return err } record.Status = drawStatusGranting record.RetryCount++ record.UpdateTime = now return nil case drawStatusGranting: return NewAppError(http.StatusConflict, "wheel_reward_granting", "wheel reward is already being granted") case drawStatusPayFailed: return NewAppError(http.StatusConflict, "wheel_reward_not_paid", "wheel reward was not paid") default: return NewAppError(http.StatusConflict, "wheel_reward_not_retryable", "wheel reward is not retryable") } }); err != nil { return err } if !shouldGrant { return nil } if err := s.grantReward(ctx, &record); err != nil { _ = s.markRecordStatus(ctx, record.ID, drawStatusFailed, err.Error()) return err } return s.markRecordSuccess(ctx, record.ID) } 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") } exists, err := s.java.ExistsGoldEvent(ctx, record.EventID) if err != nil { return err } if exists { return nil } 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 } if resourceType == resourceTypeGift { return s.java.IncrGiftBackpack(ctx, record.UserID, resourceID, 1) } if err := s.java.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{ AcceptUserID: record.UserID, PropsID: resourceID, Type: resourceType, Origin: walletOrigin, OriginDesc: walletOriginDesc, Days: days, UseProps: true, AllowGive: false, }); err != nil { return err } if err := s.java.SwitchUseProps(ctx, record.UserID, resourceID); err != nil { return err } _ = s.java.RemoveUserProfileCacheAll(ctx, record.UserID) return nil } func (s *Service) releaseDrawReservations(ctx context.Context, records []model.WheelDrawRecord, status string, message string) error { if len(records) == 0 { return nil } now := time.Now() return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, record := range records { result := tx.Model(&model.WheelDrawRecord{}). Where("id = ? AND status = ?", record.ID, drawStatusPending). Updates(map[string]any{ "status": status, "error_message": trimErrorMessage(message), "update_time": now, }) if result.Error != nil { return result.Error } if result.RowsAffected == 0 || record.RewardConfigID <= 0 { continue } if err := tx.Model(&model.WheelRewardConfig{}). Where("id = ?", record.RewardConfigID). UpdateColumn("issued_count", gorm.Expr("CASE WHEN issued_count > 0 THEN issued_count - 1 ELSE 0 END")).Error; err != nil { return err } } 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) markRecordSuccess(ctx context.Context, id int64) error { now := time.Now() return s.db.WithContext(ctx).Model(&model.WheelDrawRecord{}). Where("id = ?", id). Updates(map[string]any{ "status": drawStatusSuccess, "error_message": "", "grant_time": now, "update_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 (s *Service) loadDrawRecords(ctx context.Context, drawNo string) ([]model.WheelDrawRecord, error) { var rows []model.WheelDrawRecord err := s.db.WithContext(ctx). Where("draw_no = ?", drawNo). Order("id ASC"). Find(&rows).Error return rows, err } 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) }