362 lines
11 KiB
Go
362 lines
11 KiB
Go
package voiceroomrocket
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"chatapp3-golang/internal/integration"
|
|
"chatapp3-golang/internal/model"
|
|
"chatapp3-golang/internal/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
var errRewardLocked = errors.New("reward record is locked")
|
|
|
|
func (s *Service) createImmediateRewardRecords(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, top1UserID *int64, igniteUserID int64, now time.Time) error {
|
|
rewards, err := s.loadRewardConfigs(ctx, launch.SysOrigin, launch.Level)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, reward := range rewards {
|
|
scene := strings.ToUpper(strings.TrimSpace(reward.RewardScene))
|
|
switch scene {
|
|
case rewardSceneTop1:
|
|
if top1UserID == nil || *top1UserID <= 0 {
|
|
continue
|
|
}
|
|
if _, err := s.createRewardRecord(ctx, tx, launch, reward, *top1UserID, now); err != nil {
|
|
return err
|
|
}
|
|
case rewardSceneIgnite:
|
|
if igniteUserID <= 0 {
|
|
continue
|
|
}
|
|
if _, err := s.createRewardRecord(ctx, tx, launch, reward, igniteUserID, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) createRewardRecord(ctx context.Context, tx *gorm.DB, launch model.VoiceRoomRocketLaunch, reward model.VoiceRoomRocketRewardConfig, userID int64, now time.Time) (model.VoiceRoomRocketRewardRecord, error) {
|
|
if strings.EqualFold(strings.TrimSpace(reward.RewardType), rewardTypeNone) {
|
|
return model.VoiceRoomRocketRewardRecord{}, nil
|
|
}
|
|
id, err := utils.NextID()
|
|
if err != nil {
|
|
return model.VoiceRoomRocketRewardRecord{}, err
|
|
}
|
|
eventID := rewardGrantEventID(launch.LaunchNo, reward.RewardScene, userID, reward.ID)
|
|
record := model.VoiceRoomRocketRewardRecord{
|
|
ID: id,
|
|
LaunchID: launch.ID,
|
|
LaunchNo: launch.LaunchNo,
|
|
SysOrigin: launch.SysOrigin,
|
|
RoomID: launch.RoomID,
|
|
DayKey: launch.DayKey,
|
|
RoundNo: launch.RoundNo,
|
|
Level: launch.Level,
|
|
UserID: userID,
|
|
RewardScene: reward.RewardScene,
|
|
RewardConfigID: reward.ID,
|
|
RewardType: reward.RewardType,
|
|
RewardItemID: reward.RewardItemID,
|
|
RewardName: reward.RewardName,
|
|
RewardCover: reward.RewardCover,
|
|
RewardAmount: reward.RewardAmount,
|
|
ExpireDays: reward.ExpireDays,
|
|
GrantEventID: eventID,
|
|
GrantStatus: rewardStatusPending,
|
|
PopupStatus: popupStatusUnread,
|
|
CreateTime: now,
|
|
UpdateTime: now,
|
|
}
|
|
if err := tx.WithContext(ctx).Create(&record).Error; err != nil && !isDuplicateKeyErr(err) {
|
|
return model.VoiceRoomRocketRewardRecord{}, err
|
|
}
|
|
if err != nil && isDuplicateKeyErr(err) {
|
|
_ = tx.WithContext(ctx).Where("grant_event_id = ?", eventID).First(&record).Error
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
// RetryReward 尝试补发一条失败或待发奖励。当前先接金币,其他权益保留 PENDING 由后续 gateway 适配。
|
|
func (s *Service) RetryReward(ctx context.Context, rewardRecordID int64) error {
|
|
if rewardRecordID <= 0 {
|
|
return nil
|
|
}
|
|
var row model.VoiceRoomRocketRewardRecord
|
|
if err := s.repo.DB.WithContext(ctx).Where("id = ?", rewardRecordID).First(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.processRewardRecord(ctx, row)
|
|
}
|
|
|
|
// ProcessPendingRewards 自动扫描 PENDING 奖励记录并发放。
|
|
func (s *Service) ProcessPendingRewards(ctx context.Context, limit int) (int, error) {
|
|
if limit <= 0 {
|
|
limit = defaultWorkerBatchSize
|
|
}
|
|
var rows []model.VoiceRoomRocketRewardRecord
|
|
if err := s.repo.DB.WithContext(ctx).
|
|
Where("grant_status = ?", rewardStatusPending).
|
|
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.processRewardRecord(ctx, row); err != nil {
|
|
return processed, err
|
|
}
|
|
processed++
|
|
}
|
|
return processed, nil
|
|
}
|
|
|
|
func (s *Service) processRewardRecord(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error {
|
|
if row.ID <= 0 {
|
|
return nil
|
|
}
|
|
unlock, err := s.acquireRewardLock(ctx, row.ID)
|
|
if err != nil {
|
|
if errors.Is(err, errRewardLocked) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if unlock != nil {
|
|
defer unlock()
|
|
}
|
|
var latest model.VoiceRoomRocketRewardRecord
|
|
if err := s.repo.DB.WithContext(ctx).Where("id = ?", row.ID).First(&latest).Error; err != nil {
|
|
return err
|
|
}
|
|
if latest.GrantStatus == rewardStatusSuccess {
|
|
return nil
|
|
}
|
|
return s.grantReward(ctx, latest)
|
|
}
|
|
|
|
func (s *Service) grantReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error {
|
|
var err error
|
|
switch strings.ToUpper(strings.TrimSpace(row.RewardType)) {
|
|
case rewardTypeGold:
|
|
err = s.grantGoldReward(ctx, row)
|
|
case rewardTypeMount, rewardTypeAvatarFrame, rewardTypeChatBubble, rewardTypeVIPCard, rewardTypeGift, rewardTypeBadge:
|
|
err = s.grantPropsReward(ctx, row)
|
|
default:
|
|
err = NewAppError(http.StatusBadRequest, "unsupported_reward_type", "unsupported reward type: "+row.RewardType)
|
|
}
|
|
now := time.Now()
|
|
if err != nil {
|
|
_ = s.repo.DB.WithContext(context.Background()).
|
|
Model(&model.VoiceRoomRocketRewardRecord{}).
|
|
Where("id = ?", row.ID).
|
|
Updates(map[string]any{
|
|
"grant_status": rewardStatusFailed,
|
|
"error_message": truncateString(err.Error(), 1024),
|
|
"update_time": now,
|
|
}).Error
|
|
return err
|
|
}
|
|
if err := s.repo.DB.WithContext(ctx).
|
|
Model(&model.VoiceRoomRocketRewardRecord{}).
|
|
Where("id = ?", row.ID).
|
|
Updates(map[string]any{
|
|
"grant_status": rewardStatusSuccess,
|
|
"error_message": "",
|
|
"grant_time": now,
|
|
"update_time": now,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
_ = s.notifyRewardRecord(context.Background(), row)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) grantGoldReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error {
|
|
if s.wallet == nil {
|
|
return NewAppError(http.StatusServiceUnavailable, "wallet_unavailable", "wallet gateway is unavailable")
|
|
}
|
|
walletEventID := walletGoldRewardEventID(row.GrantEventID)
|
|
exists, err := s.wallet.ExistsGoldEvent(ctx, walletEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
if err := s.wallet.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{
|
|
ReceiptType: "INCOME",
|
|
UserID: row.UserID,
|
|
SysOrigin: row.SysOrigin,
|
|
EventID: walletEventID,
|
|
Remark: fmt.Sprintf("voice room rocket %s level %d", row.RewardScene, row.Level),
|
|
Amount: integration.NewPennyAmountPayloadFromDollar(row.RewardAmount),
|
|
CloseDelayAsset: true,
|
|
OpUserType: "BACK",
|
|
Origin: walletOriginRocketReward,
|
|
CustomizeOriginDesc: walletOriginRocketRewardDesc,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) grantPropsReward(ctx context.Context, row model.VoiceRoomRocketRewardRecord) error {
|
|
if s.rewardGateway == nil {
|
|
return NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable")
|
|
}
|
|
if row.RewardItemID == nil || *row.RewardItemID <= 0 {
|
|
return NewAppError(http.StatusBadRequest, "missing_reward_item", "rewardItemId is required for "+row.RewardType)
|
|
}
|
|
itemID := *row.RewardItemID
|
|
rewardType := strings.ToUpper(strings.TrimSpace(row.RewardType))
|
|
days := rewardDays(row)
|
|
switch rewardType {
|
|
case rewardTypeBadge:
|
|
return s.rewardGateway.ActivateTemporaryBadge(ctx, row.UserID, itemID, days)
|
|
case rewardTypeVIPCard:
|
|
if err := s.giveProps(ctx, row, itemID, propsTypeNobleVIP, days, true); err != nil {
|
|
return err
|
|
}
|
|
if err := s.grantVIPAccessories(ctx, row, itemID, days); err != nil {
|
|
return err
|
|
}
|
|
_ = s.rewardGateway.RemoveUserProfileCacheAll(ctx, row.UserID)
|
|
return nil
|
|
default:
|
|
propsType, err := propsTypeForRewardType(rewardType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
useProps := rewardType != rewardTypeGift
|
|
if err := s.giveProps(ctx, row, itemID, propsType, days, useProps); err != nil {
|
|
return err
|
|
}
|
|
if useProps {
|
|
_ = s.rewardGateway.RemoveUserProfileCacheAll(ctx, row.UserID)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (s *Service) giveProps(ctx context.Context, row model.VoiceRoomRocketRewardRecord, propsID int64, propsType string, days int, useProps bool) error {
|
|
if err := s.rewardGateway.GivePropsBackpack(ctx, integration.GivePropsBackpackRequest{
|
|
AcceptUserID: row.UserID,
|
|
PropsID: propsID,
|
|
Type: propsType,
|
|
Origin: "VOICE_ROOM_ROCKET",
|
|
OriginDesc: "Voice Room Rocket Reward",
|
|
Days: days,
|
|
UseProps: useProps,
|
|
AllowGive: false,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if useProps {
|
|
if err := s.rewardGateway.SwitchUseProps(ctx, row.UserID, propsID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) grantVIPAccessories(ctx context.Context, row model.VoiceRoomRocketRewardRecord, sourceID int64, days int) error {
|
|
ability, err := s.rewardGateway.GetNobleVIPAbility(ctx, sourceID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
grants := []struct {
|
|
id int64
|
|
typ string
|
|
}{
|
|
{int64(ability.CarID), propsTypeRide},
|
|
{int64(ability.AvatarFrameID), propsTypeAvatarFrame},
|
|
{int64(ability.ChatBubbleID), propsTypeChatBubble},
|
|
}
|
|
for _, grant := range grants {
|
|
if grant.id <= 0 {
|
|
continue
|
|
}
|
|
if err := s.giveProps(ctx, row, grant.id, grant.typ, days, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func propsTypeForRewardType(rewardType string) (string, error) {
|
|
switch strings.ToUpper(strings.TrimSpace(rewardType)) {
|
|
case rewardTypeMount:
|
|
return propsTypeRide, nil
|
|
case rewardTypeAvatarFrame:
|
|
return propsTypeAvatarFrame, nil
|
|
case rewardTypeChatBubble:
|
|
return propsTypeChatBubble, nil
|
|
case rewardTypeGift:
|
|
return propsTypeGift, nil
|
|
default:
|
|
return "", NewAppError(http.StatusBadRequest, "unsupported_reward_type", "unsupported reward type: "+rewardType)
|
|
}
|
|
}
|
|
|
|
func rewardDays(row model.VoiceRoomRocketRewardRecord) int {
|
|
if row.ExpireDays != nil && *row.ExpireDays > 0 {
|
|
return *row.ExpireDays
|
|
}
|
|
if row.RewardAmount > 0 && strings.ToUpper(strings.TrimSpace(row.RewardType)) != rewardTypeGold {
|
|
if row.RewardAmount > int64(^uint(0)>>1) {
|
|
return 1
|
|
}
|
|
return int(row.RewardAmount)
|
|
}
|
|
if strings.EqualFold(row.RewardType, rewardTypeVIPCard) {
|
|
return 30
|
|
}
|
|
return 7
|
|
}
|
|
|
|
func (s *Service) acquireRewardLock(ctx context.Context, rewardRecordID int64) (func(), error) {
|
|
if s.repo.Redis == nil {
|
|
return nil, nil
|
|
}
|
|
key := "voice_room:rocket:reward_lock:" + strconv.FormatInt(rewardRecordID, 10)
|
|
token := strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
ok, err := s.repo.Redis.SetNX(ctx, key, token, 30*time.Second).Result()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, errRewardLocked
|
|
}
|
|
return func() { _ = s.releaseRoomLock(context.Background(), key, token) }, nil
|
|
}
|
|
|
|
func rewardGrantEventID(launchNo, scene string, userID int64, rewardConfigID int64) string {
|
|
raw := fmt.Sprintf("%s:%s:%d:%d", launchNo, strings.ToUpper(strings.TrimSpace(scene)), userID, rewardConfigID)
|
|
return "VRR:" + hashString(raw)
|
|
}
|
|
|
|
func walletGoldRewardEventID(grantEventID string) string {
|
|
grantEventID = strings.TrimSpace(grantEventID)
|
|
if len(grantEventID) > 0 && len(grantEventID) <= 50 {
|
|
return grantEventID
|
|
}
|
|
return "VRR:" + hashString(grantEventID)
|
|
}
|
|
|
|
func hashString(value string) string {
|
|
sum := sha1.Sum([]byte(value))
|
|
return fmt.Sprintf("%x", sum)
|
|
}
|