710 lines
23 KiB
Go
710 lines
23 KiB
Go
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)
|
|
}
|