topfans/backend/services/taskService/service/revenue_service.go

521 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"fmt"
"time"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
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)
SetAssetLevelService(svc AssetLevelService)
}
// AssetLevelService 资产等级服务接口定义在assetService
type AssetLevelService interface {
GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error)
AddExhibitionHours(assetID int64, hours int) (string, bool, error)
CalculateRevenue(assetID int64, likeCount int, startTime, endTime int64, revenueBoostBps int) (int64, error)
}
// revenueService 展示收益Service实现
type revenueService struct {
revenueRepo repository.RevenueRepository
userRPCClient client.UserServiceClient
galleryRPCClient client.GalleryServiceClient
assetLevelService AssetLevelService // 资产等级服务
}
// NewRevenueService 创建收益Service实例
func NewRevenueService(revenueRepo repository.RevenueRepository, userRPCClient client.UserServiceClient, galleryRPCClient client.GalleryServiceClient, assetLevelService AssetLevelService) RevenueService {
return &revenueService{
revenueRepo: revenueRepo,
userRPCClient: userRPCClient,
galleryRPCClient: galleryRPCClient,
assetLevelService: assetLevelService,
}
}
// SetAssetLevelService 设置资产等级服务
func (s *revenueService) SetAssetLevelService(svc AssetLevelService) {
s.assetLevelService = svc
}
// 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,
CanClaim: r.Status == "claimable",
}
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 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),
zap.Int32("like_count", req.LikeCount))
// 计算实际上架时长(毫秒转小时)
startTime := req.StartTime
expireAt := req.ExpireAt
actualHours := (expireAt - startTime) / 3600000
// 重新计算收益使用资产等级对应的R0值而非galleryService传来的硬编码R0=5
var finalRevenue int64
if s.assetLevelService != nil && req.AssetId > 0 {
if calculatedRevenue, err := s.assetLevelService.CalculateRevenue(req.AssetId, int(req.LikeCount), startTime, expireAt, 0); err == nil {
finalRevenue = calculatedRevenue
logger.Logger.Info("OnExhibitionCompleted: recalculated revenue using asset level",
zap.Int64("asset_id", req.AssetId),
zap.Int64("original_revenue", req.CrystalAmount),
zap.Int64("recalculated_revenue", finalRevenue))
} else {
// 计算失败,使用传来的值
finalRevenue = req.CrystalAmount
logger.Logger.Warn("OnExhibitionCompleted: failed to calculate revenue with asset level, using original",
zap.Int64("asset_id", req.AssetId),
zap.Error(err))
}
} else {
finalRevenue = 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: finalRevenue, // 使用重新计算的收益
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
}
// sourceID 用于去重,避免重复累计
sourceID := fmt.Sprintf("exhibition_%d", req.ExhibitionId)
newLevel, levelDelta, crystalReward, err := s.userRPCClient.AddExhibitionHours(
ctx,
req.SlotOwnerUid,
req.OccupierStarId,
actualHours,
sourceID,
)
if err != nil {
logger.Logger.Error("OnExhibitionCompleted: AddExhibitionHours failed",
zap.Int64("slot_owner_uid", req.SlotOwnerUid),
zap.Int64("hours", actualHours),
zap.Error(err))
// 不返回错误,因为收益记录已创建
} else if levelDelta > 0 {
logger.Logger.Info("OnExhibitionCompleted: 展位主人累计上架时长触发升级",
zap.Int64("slot_owner_uid", req.SlotOwnerUid),
zap.Int64("exhibition_id", req.ExhibitionId),
zap.Int64("hours", actualHours),
zap.Int32("old_level", newLevel-levelDelta),
zap.Int32("new_level", newLevel),
zap.Int32("level_delta", levelDelta),
zap.Int64("crystal_reward", crystalReward))
}
// 增加资产累计展出时长(资产等级系统)
if s.assetLevelService != nil && req.AssetId > 0 && actualHours > 0 {
if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours)); err != nil {
logger.Logger.Warn("OnExhibitionCompleted: failed to add exhibition hours to asset level",
zap.Int64("asset_id", req.AssetId),
zap.Int64("hours", actualHours),
zap.Error(err))
} else if upgraded {
logger.Logger.Info("OnExhibitionCompleted: asset leveled up due to exhibition",
zap.Int64("asset_id", req.AssetId),
zap.String("new_level", newLevel),
zap.Int64("hours", actualHours))
}
}
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 计算单次上架收益(参考实现,未被调用)
// 注意:实际收益计算在 OnExhibitionCompleted 中通过 AssetLevelService.CalculateRevenue 实现
// 此函数保留用于参考和测试场景
// 设计文档公式:
// 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
}