package smashegg 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" ) // 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 } idempotencyRow, replayed, err := s.beginDrawIdempotency(ctx, sysOrigin, user.UserID, option.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 } poolType, rewardPool, eligibility, err := s.resolveRewardPool(ctx, bundle, user) if err != nil { return failIdempotency("", err) } if len(rewardPool) == 0 { return failIdempotency("", 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 failIdempotency("", err) } selected := make([]model.SmashEggRewardConfig, 0, option.Times) for index := 0; index < option.Times; index++ { reward, pickErr := pickReward(rewardPool) if pickErr != nil { return failIdempotency("", pickErr) } selected = append(selected, reward) } drawID, err := utils.NextID() if err != nil { return failIdempotency("", err) } drawNo := fmt.Sprintf("SMASHEGG%d", drawID) records, err := s.createPendingRecords(ctx, drawNo, user.UserID, sysOrigin, poolType, option, selected) if err != nil { return failIdempotency(drawNo, err) } if err := s.deductDrawPrice(ctx, drawNo, user.UserID, sysOrigin, option.PriceGold); err != nil { _ = s.markDrawRecords(ctx, drawNo, drawStatusFailed, err.Error()) return failIdempotency(drawNo, 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 failIdempotency(drawNo, 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 failIdempotency(drawNo, err) } } 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, poolType, option.Times, option.PriceGold, balanceAfter, eligibility.remainingAfterUse(poolType), records) 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, 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: "SMASH_EGG", SysOrigin: sysOrigin, UserID: userID, IdempotencyKey: key, RequestHash: idempotency.Hash("SMASH_EGG", 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 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) { items := make([]prizepool.PickResult, 0, len(rewards)) for index, reward := range rewards { items = append(items, prizepool.PickResult{ Item: prizepool.Item{ ID: reward.ID, Enabled: reward.Enabled, Weight: reward.Probability, }, Index: index, }) } picked, err := prizepool.PickWeighted(items) if errors.Is(err, prizepool.ErrNoAvailableItem) { return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") } if err != nil { return model.SmashEggRewardConfig{}, err } if picked.Index < 0 || picked.Index >= len(rewards) { return model.SmashEggRewardConfig{}, NewAppError(http.StatusNotFound, "smash_egg_rewards_empty", "smash egg rewards are not configured") } return rewards[picked.Index], 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 } 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) 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) }