topfans/backend/services/taskService/service/revenue_service.go
2026-05-16 02:42:32 +08:00

437 lines
15 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"
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
}