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) // 批次1.2: AddExhibitionHours 增加 sourceID 参数做幂等(sourceID 由调用方提供,如 "exhibition_") type AssetLevelService interface { GetOrCreateRecord(assetID int64) (*models.AssetLevelRecord, error) AddExhibitionHours(assetID int64, hours int, sourceID string) (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 } // sourceID 用于资产/用户两级幂等去重 — 同 exhibition 下两级 sourceID 复用同一键, // 保证 cleanup_worker 重放时既不会双加 user hours 也不会双加 asset hours。 sourceID := fmt.Sprintf("exhibition_%d", params.ExhibitionID) // slot_owner 累计时长 — 失败仅日志不重试(已 created revenue record) if s.userRPCClient != nil && params.SlotOwnerUID > 0 { 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 累计时长 — 批次1.2: 传 sourceID 做幂等(与 slot_owner 同 exhibition 共用同一键) if s.assetLevelService != nil && params.AssetID > 0 && actualHours > 0 { if _, upgraded, err := s.assetLevelService.AddExhibitionHours(params.AssetID, int(actualHours), sourceID); 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)) } } // 事件埋点 durationMs := actualHours * 3600 * 1000 if durationMs < 0 { durationMs = 0 } if params.AssetID <= 0 { logger.Logger.Warn("ProcessExhibitionRevenue: asset_id is 0, event may lack asset_id", zap.Int64("exhibition_id", params.ExhibitionID)) } 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(durationMs, 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)) } // 增加资产累计展出时长(资产等级系统)—— 批次1.2: 传 sourceID 做幂等 // 复用本函数上方已构造的 sourceID(L485),与 slot_owner(user 级)同 exhibition 共用同一键, // 保证重放/RPC 重试时用户级与资产级都不会双加。 if s.assetLevelService != nil && req.AssetId > 0 && actualHours > 0 { if newLevel, upgraded, err := s.assetLevelService.AddExhibitionHours(req.AssetId, int(actualHours), sourceID); 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.revenue(fire-and-forget) durationMs := actualHours * 3600 * 1000 if durationMs < 0 { durationMs = 0 } if req.AssetId <= 0 { logger.Logger.Warn("OnExhibitionCompleted: asset_id is 0, event may lack asset_id", zap.Int64("exhibition_id", req.ExhibitionId)) } 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(finalRevenue, 10), "duration_ms": strconv.FormatInt(durationMs, 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 内部 RPC(galleryService.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 }