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 }