393 lines
13 KiB
Go
393 lines
13 KiB
Go
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)
|
|
}
|