package smashegg 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 smash egg 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, "smash_egg_gateway_unavailable", "reward gateway is unavailable") } 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, "smash_egg_not_available", "smash egg is not enabled") } if len(bundle.DrawOptions) == 0 { return nil, NewAppError(http.StatusNotFound, "smash_egg_options_empty", "smash egg draw options are not configured") } if len(bundle.Rewards) == 0 { return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") } option, err := optionForTimes(bundle.DrawOptions, req.Times) if err != nil { return nil, err } poolType, rewardPool, eligibility, err := s.resolveRewardPool(ctx, bundle, user) if err != nil { return nil, err } if len(rewardPool) == 0 { return nil, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") } rewardPool, err = rewardsWithAutoProbabilitiesForOption(bundle.Config.RTPBasisPoints, option, rewardPool) if err != nil { return nil, err } selected := make([]model.SmashEggRewardConfig, 0, option.Times) for index := 0; index < option.Times; index++ { reward, pickErr := pickReward(rewardPool) if pickErr != nil { return nil, pickErr } selected = append(selected, reward) } drawID, err := utils.NextID() if err != nil { return nil, err } drawNo := fmt.Sprintf("SMASHEGG%d", drawID) records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected) if err != nil { return nil, err } if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil { _ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error()) return nil, mapWalletError("smash_egg_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, "smash_egg_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, poolType, option.Times, option.PriceGold, balanceAfter, eligibility.remainingAfterUse(poolType), records), nil } func optionForTimes(options []model.SmashEggDrawOptionConfig, times int) (model.SmashEggDrawOptionConfig, error) { if times <= 0 && len(options) > 0 { return options[0], nil } for _, option := range options { if option.Enabled && option.Times == times { return option, nil } } return model.SmashEggDrawOptionConfig{}, NewAppError(http.StatusBadRequest, "invalid_draw_times", "draw option is not configured") } func pickReward(rewards []model.SmashEggRewardConfig) (model.SmashEggRewardConfig, error) { total := 0 for _, reward := range rewards { if reward.Enabled && reward.Probability > 0 { total += reward.Probability } } if total <= 0 { return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") } n, err := rand.Int(rand.Reader, big.NewInt(int64(total))) if err != nil { return model.SmashEggRewardConfig{}, 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, poolType string, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig, ) ([]model.SmashEggDrawRecord, error) { now := time.Now() records := make([]model.SmashEggDrawRecord, 0, len(rewards)) for index, reward := range rewards { nextID, err := utils.NextID() if err != nil { return nil, err } records = append(records, model.SmashEggDrawRecord{ ID: nextID, DrawNo: drawNo, EventID: fmt.Sprintf("%s:REWARD:%d", drawNo, index+1), SysOrigin: sysOrigin, UserID: userID, PoolType: normalizePoolType(poolType), DrawOptionID: option.ID, DrawTimes: option.Times, PaidGold: option.PriceGold, RewardType: reward.RewardType, RewardGroupID: reward.RewardGroupID, RewardGroupName: reward.RewardGroupName, ResourceType: reward.ResourceType, ResourceURL: reward.ResourceURL, CoverURL: reward.CoverURL, AnimationURL: reward.AnimationURL, DurationDays: reward.DurationDays, DisplayGoldAmount: reward.DisplayGoldAmount, GoldAmount: reward.GoldAmount, RewardValueGold: reward.RewardValueGold, 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("smash egg draw %s", drawNo), Amount: integration.NewPennyAmountPayloadFromDollar(paidGold), CloseDelayAsset: false, OpUserType: "APP", CustomizeOrigin: walletOrigin, CustomizeOriginDesc: walletOriginDesc, }) } func (s *Service) grantReward(ctx context.Context, record *model.SmashEggDrawRecord) 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("smash egg 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.SmashEggDrawRecord) error { if record.RewardGroupID == nil || *record.RewardGroupID <= 0 { return fmt.Errorf("resource reward is empty") } days := record.DurationDays if days <= 0 { return fmt.Errorf("resource reward duration is empty") } resourceID := *record.RewardGroupID 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.SmashEggDrawRecord{}). 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.SmashEggDrawRecord{}). 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, poolType string, times int, paidGold int64, balanceAfter int64, newbieRemainingDrawCount int, records []model.SmashEggDrawRecord, ) *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, PoolType: normalizePoolType(poolType), DrawTimes: times, PaidGold: paidGold, RewardValue: rewardValue, Rewards: rewards, Records: payloadRecords, BalanceAfter: balanceAfter, NewbieRemainingDrawCount: newbieRemainingDrawCount, } } func aggregateRewards(records []model.SmashEggDrawRecord) ([]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.RewardValueGold total += value key := fmt.Sprintf("%s:%d:%s:%d:%d", record.RewardType, int64PtrValue(record.RewardGroupID), record.ResourceType, record.GoldAmount, record.DurationDays) if _, exists := buckets[key]; !exists { name := strings.TrimSpace(record.RewardGroupName) if name == "" && record.RewardType == rewardTypeGold { name = "金币" } buckets[key] = &bucket{payload: DrawRewardPayload{ RewardType: record.RewardType, ResourceID: ResourceID(int64PtrValue(record.RewardGroupID)), ResourceType: record.ResourceType, ResourceName: record.RewardGroupName, ResourceURL: record.ResourceURL, RewardGroupID: record.RewardGroupID, RewardGroupName: record.RewardGroupName, CoverURL: record.CoverURL, AnimationURL: record.AnimationURL, DurationDays: record.DurationDays, DisplayGoldAmount: record.DisplayGoldAmount, GoldAmount: record.GoldAmount, RewardValueGold: record.RewardValueGold, 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, "smash_egg_insufficient_balance", "insufficient balance") } return NewAppError(http.StatusBadGateway, code, message) }