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