437 lines
15 KiB
Go
437 lines
15 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
pbCommon "github.com/topfans/backend/pkg/proto/common"
|
||
"github.com/topfans/backend/pkg/logger"
|
||
pb "github.com/topfans/backend/pkg/proto/task"
|
||
"github.com/topfans/backend/services/taskService/client"
|
||
"github.com/topfans/backend/services/taskService/model"
|
||
"github.com/topfans/backend/services/taskService/repository"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// RevenueService 展示收益Service接口
|
||
type RevenueService interface {
|
||
GetExhibitionRevenue(ctx context.Context, userID, starID int64, status string, page, pageSize int32) (*pb.GetExhibitionRevenueResponse, error)
|
||
ClaimExhibitionRevenue(ctx context.Context, userID, starID int64, revenueID int64) (*pb.ClaimExhibitionRevenueResponse, error)
|
||
ClaimAllExhibitionRevenue(ctx context.Context, userID, starID int64) (*pb.ClaimAllExhibitionRevenueResponse, error)
|
||
OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error)
|
||
}
|
||
|
||
// revenueService 展示收益Service实现
|
||
type revenueService struct {
|
||
revenueRepo repository.RevenueRepository
|
||
userRPCClient client.UserServiceClient
|
||
galleryRPCClient client.GalleryServiceClient
|
||
}
|
||
|
||
// NewRevenueService 创建收益Service实例
|
||
func NewRevenueService(revenueRepo repository.RevenueRepository, userRPCClient client.UserServiceClient, galleryRPCClient client.GalleryServiceClient) RevenueService {
|
||
return &revenueService{
|
||
revenueRepo: revenueRepo,
|
||
userRPCClient: userRPCClient,
|
||
galleryRPCClient: galleryRPCClient,
|
||
}
|
||
}
|
||
|
||
// GetExhibitionRevenue 获取展示收益列表
|
||
func (s *revenueService) GetExhibitionRevenue(ctx context.Context, userID, starID int64, status string, page, pageSize int32) (*pb.GetExhibitionRevenueResponse, error) {
|
||
logger.Logger.Debug("GetExhibitionRevenue",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.String("status", status),
|
||
zap.Int32("page", page),
|
||
zap.Int32("page_size", pageSize))
|
||
|
||
// 设置默认值
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if pageSize <= 0 {
|
||
pageSize = 10
|
||
}
|
||
|
||
records, total, err := s.revenueRepo.ListRevenueByUser(userID, starID, status, int(page), int(pageSize))
|
||
if err != nil {
|
||
logger.Logger.Error("GetExhibitionRevenue: failed to list records",
|
||
zap.Int64("user_id", userID),
|
||
zap.Error(err))
|
||
return &pb.GetExhibitionRevenueResponse{
|
||
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK},
|
||
Items: []*pb.ExhibitionRevenueItem{},
|
||
}, nil
|
||
}
|
||
|
||
// 转换为 pb.ExhibitionRevenueItem
|
||
items := make([]*pb.ExhibitionRevenueItem, 0, len(records))
|
||
for _, r := range records {
|
||
item := &pb.ExhibitionRevenueItem{
|
||
Id: r.ID,
|
||
StarId: r.StarID,
|
||
ExhibitionId: r.ExhibitionID,
|
||
AssetId: r.AssetID,
|
||
SlotId: r.SlotID,
|
||
SlotType: r.SlotType,
|
||
CrystalAmount: r.CrystalAmount,
|
||
CycleStartTime: r.CycleStartTime,
|
||
CycleEndTime: r.CycleEndTime,
|
||
Status: r.Status,
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
|
||
return &pb.GetExhibitionRevenueResponse{
|
||
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK},
|
||
Items: items,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
Total: int64(total),
|
||
}, nil
|
||
}
|
||
|
||
// ClaimExhibitionRevenue 领取单个展示收益
|
||
func (s *revenueService) ClaimExhibitionRevenue(ctx context.Context, userID, starID int64, revenueID int64) (*pb.ClaimExhibitionRevenueResponse, error) {
|
||
logger.Logger.Info("ClaimExhibitionRevenue",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("revenue_id", revenueID))
|
||
|
||
// 获取收益记录
|
||
record, err := s.revenueRepo.GetRevenueRecord(revenueID)
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimExhibitionRevenue: failed to get record",
|
||
zap.Int64("revenue_id", revenueID),
|
||
zap.Error(err))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR}, Success: false}, err
|
||
}
|
||
|
||
if record == nil {
|
||
logger.Logger.Warn("ClaimExhibitionRevenue: record not found",
|
||
zap.Int64("revenue_id", revenueID))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_NOT_FOUND}, Success: false}, nil
|
||
}
|
||
|
||
// 检查记录所属用户
|
||
if record.UserID != userID {
|
||
logger.Logger.Warn("ClaimExhibitionRevenue: user mismatch",
|
||
zap.Int64("revenue_id", revenueID),
|
||
zap.Int64("expected_user", record.UserID),
|
||
zap.Int64("actual_user", userID))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_FORBIDDEN}, Success: false}, nil
|
||
}
|
||
|
||
// 检查状态
|
||
if record.Status != "claimable" {
|
||
logger.Logger.Warn("ClaimExhibitionRevenue: not claimable",
|
||
zap.Int64("revenue_id", revenueID),
|
||
zap.String("status", record.Status))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_BAD_REQUEST}, Success: false}, nil
|
||
}
|
||
|
||
// 发放水晶奖励
|
||
var totalBalance int64
|
||
if record.CrystalAmount > 0 {
|
||
var err error
|
||
totalBalance, err = s.userRPCClient.UpdateCrystalBalance(ctx, userID, starID, record.CrystalAmount,
|
||
"exhibition_revenue", fmt.Sprintf("%d", record.ID), fmt.Sprintf("展示收益 #%d", record.ID))
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimExhibitionRevenue: failed to update crystal",
|
||
zap.Int64("revenue_id", revenueID),
|
||
zap.Error(err))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR}, Success: false}, err
|
||
}
|
||
}
|
||
|
||
// 使用乐观锁更新记录状态
|
||
claimed, err := s.revenueRepo.ClaimRevenueRecord(revenueID, userID)
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimExhibitionRevenue: failed to claim record",
|
||
zap.Int64("revenue_id", revenueID),
|
||
zap.Error(err))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR}, Success: false}, err
|
||
}
|
||
if !claimed {
|
||
logger.Logger.Warn("ClaimExhibitionRevenue: record not claimable",
|
||
zap.Int64("revenue_id", revenueID))
|
||
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_BAD_REQUEST}, Success: false}, nil
|
||
}
|
||
|
||
// 增加用户累计上架时长(触发升级检查)
|
||
// 计算上架时长(毫秒转小时)
|
||
exhibitionHours := (record.CycleEndTime - record.CycleStartTime) / 3600000
|
||
if exhibitionHours < 1 {
|
||
exhibitionHours = 1 // 最少1小时
|
||
}
|
||
if s.userRPCClient != nil {
|
||
newLevel, levelDelta, crystalReward, err := s.userRPCClient.AddExhibitionHours(ctx, userID, starID, exhibitionHours,
|
||
fmt.Sprintf("%d", record.ID))
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimExhibitionRevenue: failed to add exhibition hours",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("exhibition_hours", exhibitionHours),
|
||
zap.Error(err))
|
||
} else if levelDelta > 0 {
|
||
logger.Logger.Info("领取收益触发升级",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("revenue_record_id", record.ID),
|
||
zap.Int32("old_level", newLevel-levelDelta),
|
||
zap.Int32("new_level", newLevel),
|
||
zap.Int32("level_delta", levelDelta),
|
||
zap.Int64("crystal_reward", crystalReward))
|
||
}
|
||
}
|
||
|
||
// 调用 Gallery Service 下架展品
|
||
if s.galleryRPCClient != nil && record.AssetID > 0 {
|
||
if err := s.galleryRPCClient.RemoveExhibitionByAsset(ctx, record.AssetID); err != nil {
|
||
logger.Logger.Error("ClaimExhibitionRevenue: failed to remove exhibition",
|
||
zap.Int64("asset_id", record.AssetID),
|
||
zap.Error(err))
|
||
// 不阻断主流程,展品可能已经在cleanup时删除了
|
||
} else {
|
||
logger.Logger.Info("ClaimExhibitionRevenue: exhibition removed",
|
||
zap.Int64("asset_id", record.AssetID))
|
||
}
|
||
}
|
||
|
||
logger.Logger.Info("ClaimExhibitionRevenue: success",
|
||
zap.Int64("revenue_id", revenueID))
|
||
|
||
return &pb.ClaimExhibitionRevenueResponse{
|
||
Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK},
|
||
Success: true,
|
||
CrystalAmount: record.CrystalAmount,
|
||
TotalBalance: totalBalance,
|
||
}, nil
|
||
}
|
||
|
||
// ClaimAllExhibitionRevenue 一键领取所有可领取的展示收益
|
||
func (s *revenueService) ClaimAllExhibitionRevenue(ctx context.Context, userID, starID int64) (*pb.ClaimAllExhibitionRevenueResponse, error) {
|
||
logger.Logger.Info("ClaimAllExhibitionRevenue",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID))
|
||
|
||
// 获取所有可领取的记录
|
||
records, _, err := s.revenueRepo.ListRevenueByUser(userID, starID, "claimable", 1, 1000)
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimAllExhibitionRevenue: failed to list claimable",
|
||
zap.Int64("user_id", userID),
|
||
zap.Error(err))
|
||
return &pb.ClaimAllExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR}, ClaimedCount: 0}, err
|
||
}
|
||
|
||
claimedCount := 0
|
||
|
||
for _, record := range records {
|
||
// 发放水晶奖励
|
||
if record.CrystalAmount > 0 {
|
||
_, err := s.userRPCClient.UpdateCrystalBalance(ctx, userID, starID, record.CrystalAmount,
|
||
"exhibition_revenue", fmt.Sprintf("%d", record.ID), fmt.Sprintf("展示收益 #%d", record.ID))
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimAllExhibitionRevenue: failed to update crystal",
|
||
zap.Int64("revenue_id", record.ID),
|
||
zap.Error(err))
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 使用乐观锁更新记录状态
|
||
claimed, err := s.revenueRepo.ClaimRevenueRecord(record.ID, userID)
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimAllExhibitionRevenue: failed to claim record",
|
||
zap.Int64("revenue_id", record.ID),
|
||
zap.Error(err))
|
||
continue
|
||
}
|
||
|
||
if claimed {
|
||
claimedCount++
|
||
|
||
// 增加用户累计上架时长(触发升级检查)
|
||
exhibitionHours := (record.CycleEndTime - record.CycleStartTime) / 3600000
|
||
if exhibitionHours < 1 {
|
||
exhibitionHours = 1
|
||
}
|
||
if s.userRPCClient != nil {
|
||
newLevel, levelDelta, crystalReward, err := s.userRPCClient.AddExhibitionHours(ctx, userID, starID, exhibitionHours,
|
||
fmt.Sprintf("%d", record.ID))
|
||
if err != nil {
|
||
logger.Logger.Error("ClaimAllExhibitionRevenue: failed to add exhibition hours",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("exhibition_hours", exhibitionHours),
|
||
zap.Error(err))
|
||
} else if levelDelta > 0 {
|
||
logger.Logger.Info("一键领取触发升级",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int64("star_id", starID),
|
||
zap.Int64("revenue_record_id", record.ID),
|
||
zap.Int32("old_level", newLevel-levelDelta),
|
||
zap.Int32("new_level", newLevel),
|
||
zap.Int32("level_delta", levelDelta),
|
||
zap.Int64("crystal_reward", crystalReward))
|
||
}
|
||
}
|
||
|
||
// 调用 Gallery Service 下架展品
|
||
if s.galleryRPCClient != nil && record.AssetID > 0 {
|
||
if err := s.galleryRPCClient.RemoveExhibitionByAsset(ctx, record.AssetID); err != nil {
|
||
logger.Logger.Error("ClaimAllExhibitionRevenue: failed to remove exhibition",
|
||
zap.Int64("asset_id", record.AssetID),
|
||
zap.Error(err))
|
||
} else {
|
||
logger.Logger.Info("ClaimAllExhibitionRevenue: exhibition removed",
|
||
zap.Int64("asset_id", record.AssetID))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.Logger.Info("ClaimAllExhibitionRevenue: done",
|
||
zap.Int64("user_id", userID),
|
||
zap.Int("claimed_count", claimedCount))
|
||
|
||
return &pb.ClaimAllExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK}, ClaimedCount: int32(claimedCount)}, nil
|
||
}
|
||
|
||
// OnExhibitionCompleted 当展位到期完成时由 galleryService 调用
|
||
// 收益归属资产主人(铸爱用户),展位主人不再获得收益(无论是否同一人)
|
||
func (s *revenueService) OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error) {
|
||
logger.Logger.Info("OnExhibitionCompleted",
|
||
zap.Int64("exhibition_id", req.ExhibitionId),
|
||
zap.Int64("asset_id", req.AssetId),
|
||
zap.Int64("slot_id", req.SlotId),
|
||
zap.Int64("occupier_uid", req.OccupierUid),
|
||
zap.Int64("slot_owner_uid", req.SlotOwnerUid),
|
||
zap.Int64("crystal_amount", req.CrystalAmount))
|
||
|
||
// 收益归属资产主人(铸爱用户),无论展位是否为自己
|
||
now := time.Now().UnixMilli()
|
||
record := &model.ExhibitionRevenueRecord{
|
||
UserID: req.OccupierUid, // 收益归资产主人(铸爱用户)
|
||
StarID: req.OccupierStarId,
|
||
ExhibitionID: req.ExhibitionId,
|
||
AssetID: req.AssetId,
|
||
SlotID: req.SlotId,
|
||
SlotOwnerUID: req.SlotOwnerUid, // 记录展位所有者信息(仅供参考)
|
||
SlotType: "exhibition", // 上架展示收益
|
||
CrystalAmount: req.CrystalAmount,
|
||
CycleStartTime: req.StartTime,
|
||
CycleEndTime: req.ExpireAt,
|
||
Status: "claimable",
|
||
CreatedAt: now,
|
||
}
|
||
|
||
createdRecord, err := s.revenueRepo.CreateRevenueRecord(record)
|
||
if err != nil {
|
||
logger.Logger.Error("OnExhibitionCompleted: failed to create record",
|
||
zap.Int64("exhibition_id", req.ExhibitionId),
|
||
zap.Error(err))
|
||
return &pb.OnExhibitionCompletedResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR}}, err
|
||
}
|
||
|
||
logger.Logger.Info("OnExhibitionCompleted: success",
|
||
zap.Int64("exhibition_id", req.ExhibitionId),
|
||
zap.Int64("revenue_record_id", createdRecord.ID))
|
||
|
||
return &pb.OnExhibitionCompletedResponse{Base: &pbCommon.BaseResponse{Code: pbCommon.StatusCode_STATUS_OK}, RevenueRecordId: createdRecord.ID}, nil
|
||
}
|
||
|
||
// CalculateBuff 根据点赞数计算Buff百分比
|
||
// 设计文档公式:
|
||
// n < 5 → 0%
|
||
// 5 ≤ n < 10 → 10%
|
||
// 10 ≤ n < 30 → 20%
|
||
// n ≥ 30 → 30% (封顶)
|
||
func CalculateBuff(likeCount int) int {
|
||
switch {
|
||
case likeCount >= 30:
|
||
return 30
|
||
case likeCount >= 10:
|
||
return 20
|
||
case likeCount >= 5:
|
||
return 10
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// CalculateExhibitionRevenue 计算单次上架收益
|
||
// 设计文档公式:
|
||
// R1 = R0 × T × [100% + Buff(n)]
|
||
// R0 = 5 水晶/小时
|
||
// T = 上架时长(小时)
|
||
// Buff(n) 根据点赞数计算
|
||
// 应用永久收益提升:revenueBoostBps (bps),如 500 = +5%
|
||
func CalculateExhibitionRevenue(likeCount int, startTime, endTime int64, revenueBoostBps int) int64 {
|
||
R0 := int64(5) // 水晶/小时
|
||
|
||
// 计算上架时长(毫秒转小时)
|
||
T := (endTime - startTime) / 3600000
|
||
if T <= 0 {
|
||
T = 1 // 最少1小时
|
||
}
|
||
|
||
// 计算Buff
|
||
buff := CalculateBuff(likeCount)
|
||
|
||
// 基础收益
|
||
baseRevenue := R0 * T
|
||
|
||
// 应用Buff加成
|
||
// R1 = R0 × T × (100% + Buff)
|
||
buffedRevenue := baseRevenue * (100 + int64(buff)) / 100
|
||
|
||
// 应用永久收益提升
|
||
if revenueBoostBps > 0 {
|
||
boost := buffedRevenue * int64(revenueBoostBps) / 10000
|
||
buffedRevenue += boost
|
||
}
|
||
|
||
logger.Logger.Debug("CalculateExhibitionRevenue",
|
||
zap.Int("like_count", likeCount),
|
||
zap.Int64("hours", T),
|
||
zap.Int("buff_percent", buff),
|
||
zap.Int64("base_revenue", baseRevenue),
|
||
zap.Int64("buffed_revenue", buffedRevenue),
|
||
zap.Int("revenue_boost_bps", revenueBoostBps))
|
||
|
||
return buffedRevenue
|
||
}
|
||
|
||
// CalculateLikeBetRevenue 计算点赞押注收益
|
||
// 设计文档公式:
|
||
// R2 = [1 + (N - i)] × R3
|
||
// N = 藏品下架时的总点赞数
|
||
// i = 第几位押注者(1=第一个点赞)
|
||
// R3 = 新增一个赞提供的奖励 = 1 水晶 / 2 = 0.5 水晶,但代码用整数,所以取 1
|
||
// R2 最高为 100
|
||
func CalculateLikeBetRevenue(totalLikes int, betOrder int) int64 {
|
||
if betOrder <= 0 || totalLikes <= 0 {
|
||
return 0
|
||
}
|
||
|
||
R3 := int64(1) // 每新增一个赞奖励1水晶
|
||
|
||
// R2 = [1 + (N - i)] × R3
|
||
revenue := int64(1+(totalLikes-betOrder)) * R3
|
||
|
||
// 封顶100
|
||
if revenue > 100 {
|
||
revenue = 100
|
||
}
|
||
|
||
logger.Logger.Debug("CalculateLikeBetRevenue",
|
||
zap.Int("total_likes", totalLikes),
|
||
zap.Int("bet_order", betOrder),
|
||
zap.Int64("revenue", revenue))
|
||
|
||
return revenue
|
||
}
|