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 }