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

940 lines
35 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"
"strconv"
"time"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/proto/event"
pb "github.com/topfans/backend/pkg/proto/task"
"github.com/topfans/backend/pkg/statistic"
"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"
"google.golang.org/grpc/codes"
)
// 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)
// ProcessExhibitionRevenue 是 OnExhibitionCompleted 的内部 helper,
// 接收 MQ payload 结构,供 mq/consumer.go 使用。
ProcessExhibitionRevenue(ctx context.Context, params ProcessExhibitionRevenueParams) (int64, error)
// 点赞押注收益(用户点赞别人作品、展品到期后获得的押注奖励)
RecordLikeBetRevenue(ctx context.Context, exhibitionID, assetID, totalLikes, startTime, expireAt int64) (*pb.RecordLikeBetRevenueResponse, error)
GetLikeBetRevenue(ctx context.Context, userID, starID int64, status string, page, pageSize int32) (*pb.GetLikeBetRevenueResponse, error)
ClaimLikeBetRevenue(ctx context.Context, userID, starID int64, revenueID int64) (*pb.ClaimLikeBetRevenueResponse, error)
ClaimAllLikeBetRevenue(ctx context.Context, userID, starID int64) (*pb.ClaimAllLikeBetRevenueResponse, 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
likeBetRepo repository.LikeBetRevenueRepository
userRPCClient client.UserServiceClient
galleryRPCClient client.GalleryServiceClient
assetLevelService AssetLevelService // 资产等级服务
}
// NewRevenueService 创建收益Service实例
func NewRevenueService(revenueRepo repository.RevenueRepository, likeBetRepo repository.LikeBetRevenueRepository, userRPCClient client.UserServiceClient, galleryRPCClient client.GalleryServiceClient, assetLevelService AssetLevelService) RevenueService {
return &revenueService{
revenueRepo: revenueRepo,
likeBetRepo: likeBetRepo,
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: uint32(codes.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: uint32(codes.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: uint32(codes.Internal)}, 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: uint32(codes.NotFound)}, 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: uint32(codes.PermissionDenied)}, 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: uint32(codes.InvalidArgument)}, 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: uint32(codes.Internal)}, 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: uint32(codes.Internal)}, Success: false}, err
}
if !claimed {
logger.Logger.Warn("ClaimExhibitionRevenue: record not claimable",
zap.Int64("revenue_id", revenueID))
return &pb.ClaimExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.InvalidArgument)}, Success: false}, nil
}
// 调用 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: uint32(codes.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: uint32(codes.Internal)}, 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++
// 调用 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))
}
}
}
}
logger.Logger.Info("ClaimAllExhibitionRevenue: done",
zap.Int64("user_id", userID),
zap.Int("claimed_count", claimedCount))
return &pb.ClaimAllExhibitionRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)}, ClaimedCount: int32(claimedCount)}, nil
}
// ProcessExhibitionRevenue 内部 helper — 同时服务 RPC 入口和 MQ handler 入口。
//
// 入参即 MQ payload,所有字段含义与 proto request 对齐。
// 返回 RevenueRecord ID 用于审计;error 不外抛,handler 内已 ERROR 日志。
func (s *revenueService) ProcessExhibitionRevenue(ctx context.Context, params ProcessExhibitionRevenueParams) (int64, error) {
logger.Logger.Info("ProcessExhibitionRevenue",
zap.Int64("exhibition_id", params.ExhibitionID),
zap.Int64("asset_id", params.AssetID),
zap.Int64("slot_id", params.SlotID),
zap.Int64("occupier_uid", params.OccupierUID),
zap.Int64("slot_owner_uid", params.SlotOwnerUID),
zap.Int32("like_count", params.LikeCount))
// 防御:repo 为 nil(理论上不会,但避免 Asynq handler 内 panic 导致整个 server 崩)
if s.revenueRepo == nil {
logger.Logger.Error("ProcessExhibitionRevenue: revenueRepo is nil, skip")
return 0, fmt.Errorf("revenueRepo is nil")
}
// 幂等:按 exhibition_id 查已存在记录,存在则直接返回原 ID
if existing, err := s.revenueRepo.GetByExhibitionID(params.ExhibitionID); err == nil && existing != nil && existing.ID > 0 {
logger.Logger.Info("exhibition revenue already exists, skip",
zap.Int64("exhibition_id", params.ExhibitionID),
zap.Int64("existing_revenue_id", existing.ID))
return existing.ID, nil
}
startTime := params.StartTime
if startTime > 0 && startTime < 10000000000 {
startTime = startTime * 1000
logger.Logger.Warn("ProcessExhibitionRevenue: converted start_time to ms",
zap.Int64("exhibition_id", params.ExhibitionID))
}
expireAt := params.ExpireAt
actualHours := (expireAt - startTime) / 3600000
// 重新计算收益
var finalRevenue int64 = params.CrystalAmount
if s.assetLevelService != nil && params.AssetID > 0 {
if recalculated, err := s.assetLevelService.CalculateRevenue(
params.AssetID, int(params.LikeCount), startTime, expireAt, 0); err == nil && recalculated > 0 {
finalRevenue = recalculated
}
}
now := time.Now().UnixMilli()
record := &model.ExhibitionRevenueRecord{
UserID: params.OccupierUID,
StarID: params.OccupierStarID,
ExhibitionID: params.ExhibitionID,
AssetID: params.AssetID,
SlotID: params.SlotID,
SlotOwnerUID: params.SlotOwnerUID,
SlotType: "exhibition",
CrystalAmount: finalRevenue,
CycleStartTime: startTime,
CycleEndTime: expireAt,
Status: "claimable",
CreatedAt: now,
}
createdRecord, err := s.revenueRepo.CreateRevenueRecord(record)
if err != nil {
logger.Logger.Error("ProcessExhibitionRevenue: create failed",
zap.Int64("exhibition_id", params.ExhibitionID),
zap.Error(err))
return 0, err
}
// slot_owner 累计时长 — 失败仅日志不重试(已 created revenue record)
if s.userRPCClient != nil && params.SlotOwnerUID > 0 {
sourceID := fmt.Sprintf("exhibition_%d", params.ExhibitionID)
if _, _, _, err := s.userRPCClient.AddExhibitionHours(
ctx, params.SlotOwnerUID, params.OccupierStarID, actualHours, sourceID,
); err != nil {
logger.Logger.Warn("slot_owner AddExhibitionHours failed",
zap.Int64("slot_owner_uid", params.SlotOwnerUID),
zap.Error(err))
}
}
// asset 累计时长
if s.assetLevelService != nil && params.AssetID > 0 && actualHours > 0 {
if _, upgraded, err := s.assetLevelService.AddExhibitionHours(params.AssetID, int(actualHours)); err != nil {
logger.Logger.Warn("asset AddExhibitionHours failed",
zap.Int64("asset_id", params.AssetID),
zap.Error(err))
} else if upgraded {
logger.Logger.Info("asset leveled up due to exhibition (via MQ path)",
zap.Int64("asset_id", params.AssetID))
}
}
// 事件埋点
statistic.Get().TrackEvent(context.Background(), &event.Event{
EventType: "exhibition.revenue",
UserId: params.OccupierUID,
StarId: params.OccupierStarID,
OccurredAt: now,
Properties: map[string]string{
"asset_id": strconv.FormatInt(params.AssetID, 10),
"amount": strconv.FormatInt(finalRevenue, 10),
"duration_ms": strconv.FormatInt(actualHours*3600*1000, 10),
},
})
logger.Logger.Info("ProcessExhibitionRevenue: success",
zap.Int64("exhibition_id", params.ExhibitionID),
zap.Int64("revenue_id", createdRecord.ID))
return createdRecord.ID, nil
}
// ProcessExhibitionRevenueParams 是 ProcessExhibitionRevenue 的入参结构(MQ 与 RPC 共用)。
type ProcessExhibitionRevenueParams struct {
ExhibitionID int64
AssetID int64
SlotID int64
OccupierUID int64
OccupierStarID int64
SlotOwnerUID int64
StartTime int64
ExpireAt int64
LikeCount int32
CrystalAmount int64 // 仅做 fallback,正常由 AssetLevelService.CalculateRevenue 重算覆盖
}
// OnExhibitionCompleted 当展位到期完成时由 galleryService 调用(同步 RPC,本期内废弃,以 MQ handler 替代)。
// 保留此函数签名以兼容可能存在的 admin 重放或其他边界场景。
// 后续会在 galleryService.cleanup_worker 完全去除后用 stub 实现。
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))
// 计算实际上架时长(毫秒转小时)
// 确保时间戳是毫秒级(防护:秒级时间戳通常 < 10000000000
startTime := req.StartTime
if startTime > 0 && startTime < 10000000000 {
startTime = startTime * 1000
logger.Logger.Warn("OnExhibitionCompleted: converted start_time from seconds to milliseconds",
zap.Int64("exhibition_id", req.ExhibitionId),
zap.Int64("original_start_time", req.StartTime),
zap.Int64("converted_start_time", 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: 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: uint32(codes.Internal)}}, 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))
// 事件埋点exhibition.revenuefire-and-forget
statistic.Get().TrackEvent(context.Background(), &event.Event{
EventType: "exhibition.revenue",
UserId: req.OccupierUid,
StarId: req.OccupierStarId,
OccurredAt: time.Now().UnixMilli(),
Properties: map[string]string{
"asset_id": strconv.FormatInt(req.AssetId, 10),
"amount": strconv.FormatInt(crystalReward, 10),
"duration_ms": strconv.FormatInt(actualHours*3600*1000, 10),
},
})
return &pb.OnExhibitionCompletedResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.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
}
}
// roundHalfToEven 银行家四舍五入round half to even
// 当小数部分恰好为 0.5 时,向最近的偶数舍入,避免统计偏差
// 例如2.5 → 2, 3.5 → 4, 4.5 → 4, 5.5 → 6
func roundHalfToEven(numerator, denominator int64) int64 {
quotient := numerator / denominator
remainder := numerator % denominator
half := denominator / 2
if remainder > half || (remainder == half && quotient%2 != 0) {
quotient++
}
return quotient
}
// 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 := roundHalfToEven(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 = 该展览内所有押注者的总数len(likes)NOT 藏品全局点赞数
// i = 第几位押注者1=第一个押注,按 created_at ASC 排序)
// N - i = 该押注者之后,同一展览内又新增的押注数
// R3 = 新增一个赞提供的奖励 = 1 水晶 / 2 = 0.5 水晶,但代码用整数,所以取 1
// R2 最高为 100
//
// 重要N 和 i 必须处于同一个 exhibition scope否则公式不成立。
// 用藏品全局点赞数作为 N 会导致跨展览周期时 bet_order 偏移,多算收益。
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
}
// =====================================================================
// 点赞押注收益(用户点赞别人作品 → 展品到期后获得的押注奖励)
// =====================================================================
// RecordLikeBetRevenue 内部 RPCgalleryService.cleanup_worker 调用)
// 流程:按 exhibition_id 查所有 asset_likes → 按 created_at ASC 算 bet_order
//
// → 用 CalculateLikeBetRevenue(exhibitionTotalLikes, betOrder) 计算每笔金额
// → 批量写入 like_bet_revenue_records
//
// 幂等DB 唯一约束 uk_like_bet_unique(exhibition_id, like_id) 保证 cleanup_worker 重跑不会重复发奖
//
// 注意totalLikes 使用展览内点赞数 len(likes),而非调用方传入的藏品全局点赞数。
// 确保 N 和 bet_order 在同一个 exhibition scope 内。
func (s *revenueService) RecordLikeBetRevenue(ctx context.Context, exhibitionID, assetID, totalLikes, startTime, expireAt int64) (*pb.RecordLikeBetRevenueResponse, error) {
logger.Logger.Info("RecordLikeBetRevenue",
zap.Int64("exhibition_id", exhibitionID),
zap.Int64("asset_id", assetID),
zap.Int64("total_likes", totalLikes),
zap.Int64("start_time", startTime),
zap.Int64("expire_at", expireAt))
// 1. 查该 exhibition 下所有点赞(按 created_at ASC, id ASC 排序得到 bet_order
type likeRow struct {
ID int64
UserID int64
StarID int64
CreatedAt int64
}
var likes []likeRow
err := database.GetDB().Table("asset_likes").
Select("id, user_id, star_id, created_at").
Where("exhibition_id = ?", exhibitionID).
Order("created_at ASC, id ASC").
Scan(&likes).Error
if err != nil {
logger.Logger.Error("RecordLikeBetRevenue: failed to query asset_likes",
zap.Int64("exhibition_id", exhibitionID),
zap.Error(err))
return &pb.RecordLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)},
CreatedCount: 0,
}, err
}
if len(likes) == 0 {
logger.Logger.Info("RecordLikeBetRevenue: no likes found, skip",
zap.Int64("exhibition_id", exhibitionID))
return &pb.RecordLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
CreatedCount: 0,
RevenueRecordIds: []int64{},
}, nil
}
// 2. 按 bet_order 计算每笔金额,组装写入批次
// 注意:使用展览内的点赞总数 len(likes),而非藏品全局点赞数
// 这样 N 和 bet_order 都在同一个 exhibition scope 内,公式 N - i 才正确表示"该押注者之后的新点赞数"
exhibitionTotalLikes := len(likes)
records := make([]*model.LikeBetRevenueRecord, 0, len(likes))
now := time.Now().UnixMilli()
for i, like := range likes {
betOrder := i + 1
amount := CalculateLikeBetRevenue(exhibitionTotalLikes, betOrder)
if amount <= 0 {
continue
}
records = append(records, &model.LikeBetRevenueRecord{
UserID: like.UserID,
StarID: like.StarID,
ExhibitionID: exhibitionID,
AssetID: assetID,
LikeID: like.ID,
BetOrder: betOrder,
TotalLikes: exhibitionTotalLikes,
CrystalAmount: amount,
CycleStartTime: startTime,
CycleEndTime: expireAt,
Status: "claimable",
CreatedAt: now,
})
}
if len(records) == 0 {
return &pb.RecordLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
CreatedCount: 0,
RevenueRecordIds: []int64{},
}, nil
}
// 3. 批量写入(重跑时唯一约束会忽略重复项,外层清理 warning 即可)
if err := s.likeBetRepo.BatchCreate(records); err != nil {
logger.Logger.Error("RecordLikeBetRevenue: failed to BatchCreate",
zap.Int64("exhibition_id", exhibitionID),
zap.Int("count", len(records)),
zap.Error(err))
return &pb.RecordLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)},
CreatedCount: 0,
}, err
}
// 4. 收集本次写入的 ID供审计
recordIDs := make([]int64, 0, len(records))
for _, r := range records {
recordIDs = append(recordIDs, r.ID)
}
logger.Logger.Info("RecordLikeBetRevenue: success",
zap.Int64("exhibition_id", exhibitionID),
zap.Int("created_count", len(records)))
return &pb.RecordLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
CreatedCount: int32(len(records)),
RevenueRecordIds: recordIDs,
}, nil
}
// GetLikeBetRevenue mobile GET查询当前用户的点赞押注收益列表
func (s *revenueService) GetLikeBetRevenue(ctx context.Context, userID, starID int64, status string, page, pageSize int32) (*pb.GetLikeBetRevenueResponse, error) {
logger.Logger.Debug("GetLikeBetRevenue",
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.likeBetRepo.ListByUser(userID, starID, status, int(page), int(pageSize))
if err != nil {
logger.Logger.Error("GetLikeBetRevenue: failed to list records",
zap.Int64("user_id", userID),
zap.Error(err))
return &pb.GetLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
Items: []*pb.LikeBetRevenueItem{},
}, nil
}
items := make([]*pb.LikeBetRevenueItem, 0, len(records))
for _, r := range records {
item := &pb.LikeBetRevenueItem{
Id: r.ID,
StarId: r.StarID,
ExhibitionId: r.ExhibitionID,
AssetId: r.AssetID,
LikeId: r.LikeID,
BetOrder: int32(r.BetOrder),
TotalLikes: int32(r.TotalLikes),
CrystalAmount: r.CrystalAmount,
CycleStartTime: r.CycleStartTime,
CycleEndTime: r.CycleEndTime,
Status: r.Status,
CanClaim: r.Status == "claimable",
}
items = append(items, item)
}
return &pb.GetLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
Items: items,
Page: page,
PageSize: pageSize,
Total: int64(total),
}, nil
}
// ClaimLikeBetRevenue mobile POST领取单条点赞押注收益
// 镜像 ClaimExhibitionRevenue 但**不调 RemoveExhibitionByAsset**(点赞收益不需要下架展品)
func (s *revenueService) ClaimLikeBetRevenue(ctx context.Context, userID, starID int64, revenueID int64) (*pb.ClaimLikeBetRevenueResponse, error) {
logger.Logger.Info("ClaimLikeBetRevenue",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int64("revenue_id", revenueID))
// 1. 校验记录存在性 + 归属
record, err := s.likeBetRepo.GetRecord(revenueID)
if err != nil {
logger.Logger.Error("ClaimLikeBetRevenue: failed to get record",
zap.Int64("revenue_id", revenueID),
zap.Error(err))
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, Success: false}, err
}
if record == nil {
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.NotFound)}, Success: false}, nil
}
if record.UserID != userID {
logger.Logger.Warn("ClaimLikeBetRevenue: user mismatch",
zap.Int64("revenue_id", revenueID),
zap.Int64("expected_user", record.UserID),
zap.Int64("actual_user", userID))
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.PermissionDenied)}, Success: false}, nil
}
if record.Status != "claimable" {
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.InvalidArgument), Message: "record not claimable"}, Success: false}, nil
}
// 2. 发放水晶奖励
var totalBalance int64
if record.CrystalAmount > 0 {
var err error
totalBalance, err = s.userRPCClient.UpdateCrystalBalance(ctx, userID, starID, record.CrystalAmount,
"like_bet_revenue", fmt.Sprintf("%d", record.ID), fmt.Sprintf("点赞押注收益 #%d", record.ID))
if err != nil {
logger.Logger.Error("ClaimLikeBetRevenue: failed to update crystal",
zap.Int64("revenue_id", revenueID),
zap.Error(err))
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, Success: false}, err
}
}
// 3. 乐观锁更新为 claimed
claimed, err := s.likeBetRepo.ClaimRecord(revenueID, userID)
if err != nil {
logger.Logger.Error("ClaimLikeBetRevenue: failed to claim record",
zap.Int64("revenue_id", revenueID),
zap.Error(err))
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, Success: false}, err
}
if !claimed {
return &pb.ClaimLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.InvalidArgument), Message: "record not claimable"}, Success: false}, nil
}
logger.Logger.Info("ClaimLikeBetRevenue: success",
zap.Int64("revenue_id", revenueID),
zap.Int64("crystal_amount", record.CrystalAmount))
return &pb.ClaimLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
Success: true,
CrystalAmount: record.CrystalAmount,
TotalBalance: totalBalance,
}, nil
}
// ClaimAllLikeBetRevenue mobile POST一键领取所有可领取的点赞押注收益
func (s *revenueService) ClaimAllLikeBetRevenue(ctx context.Context, userID, starID int64) (*pb.ClaimAllLikeBetRevenueResponse, error) {
logger.Logger.Info("ClaimAllLikeBetRevenue",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID))
records, _, err := s.likeBetRepo.ListByUser(userID, starID, "claimable", 1, 1000)
if err != nil {
logger.Logger.Error("ClaimAllLikeBetRevenue: failed to list claimable",
zap.Int64("user_id", userID),
zap.Error(err))
return &pb.ClaimAllLikeBetRevenueResponse{Base: &pbCommon.BaseResponse{Code: uint32(codes.Internal)}, ClaimedCount: 0}, err
}
claimedCount := 0
for _, record := range records {
if record.CrystalAmount > 0 {
_, err := s.userRPCClient.UpdateCrystalBalance(ctx, userID, starID, record.CrystalAmount,
"like_bet_revenue", fmt.Sprintf("%d", record.ID), fmt.Sprintf("点赞押注收益 #%d", record.ID))
if err != nil {
logger.Logger.Error("ClaimAllLikeBetRevenue: failed to update crystal",
zap.Int64("revenue_id", record.ID),
zap.Error(err))
continue
}
}
claimed, err := s.likeBetRepo.ClaimRecord(record.ID, userID)
if err != nil {
logger.Logger.Error("ClaimAllLikeBetRevenue: failed to claim record",
zap.Int64("revenue_id", record.ID),
zap.Error(err))
continue
}
if claimed {
claimedCount++
}
}
logger.Logger.Info("ClaimAllLikeBetRevenue: done",
zap.Int64("user_id", userID),
zap.Int("claimed_count", claimedCount))
return &pb.ClaimAllLikeBetRevenueResponse{
Base: &pbCommon.BaseResponse{Code: uint32(codes.OK)},
ClaimedCount: int32(claimedCount),
}, nil
}