- settlement(1.1): exhibition_revenue_records 加 UNIQUE(exhibition_id,cycle_start_time) + CreateRevenueRecord ON CONFLICT DO NOTHING; MQ 用 settled_at 替代复用 is_processed; 恢复扫描器过滤改 settled_at IS NULL; created_at 统一毫秒; 删死代码 cleanup_worker.go。 - hours(1.2): 新增 exhibition_hours_log/asset_exhibition_hours_log(source_id 唯一)幂等表; fan_profile/assetLevel 的 AddExhibitionHours 按 sourceID 幂等(事务包裹); 存量重算脚本。 - mint(1.4/1.5/1.6): crystal_transaction_records (source_id,change_type) 部分唯一索引 + UpdateCrystalBalance/CreateMintOrder 幂等; 保底改 crypto/rand; 下线伪 tx_hash; doMint Redis Lua 原子限流。 - migrations 001/002/003; 各服务单测(自包含, 缺 DB t.Skip)。 Co-Authored-By: Claude <noreply@anthropic.com>
962 lines
36 KiB
Go
962 lines
36 KiB
Go
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_<id>")
|
||
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
|
||
}
|