2026-05-15 23:03:56 +08:00

462 lines
14 KiB
Go

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
}
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.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)
}