topfans/backend/services/assetService/service/asset_service.go

790 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"context"
"errors"
"fmt"
"sort"
"time"
appErrors "github.com/topfans/backend/pkg/errors"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/asset"
pbCommon "github.com/topfans/backend/pkg/proto/common"
"github.com/topfans/backend/pkg/validator"
"github.com/topfans/backend/services/assetService/client"
"github.com/topfans/backend/services/assetService/repository"
"go.uber.org/zap"
"gorm.io/gorm"
)
// AssetService 资产服务接口
type AssetService interface {
// GetMyAssets 获取我的藏品列表
GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID int64) (*pb.GetMyAssetsResponse, error)
// GetAsset 获取资产详情
GetAsset(req *pb.GetAssetRequest, userID, starID int64) (*pb.GetAssetResponse, error)
// GetAssetStatus 查询上链状态
GetAssetStatus(req *pb.GetAssetStatusRequest, userID, starID int64) (*pb.GetAssetStatusResponse, error)
// GetAssetForRPC 获取资产信息内部RPC调用
GetAssetForRPC(req *pb.GetAssetForRPCRequest) (*pb.GetAssetForRPCResponse, error)
}
// RegistryRepository 资产注册表Repository接口用于分组查询
type RegistryRepository interface {
// GetByOwner 查询用户的注册记录
GetByOwner(ownerUID, starID int64) ([]*models.AssetRegistry, error)
}
// assetService 资产服务实现
type assetService struct {
assetRepo repository.AssetRepository
mintOrderRepo repository.MintOrderRepository
assetLikeRepo repository.AssetLikeRepository
userClient client.UserServiceClient
db *gorm.DB
registryRepo RegistryRepository
}
// NewAssetService 创建资产服务实例
func NewAssetService(
assetRepo repository.AssetRepository,
mintOrderRepo repository.MintOrderRepository,
assetLikeRepo repository.AssetLikeRepository,
userClient client.UserServiceClient,
db *gorm.DB,
registryRepo RegistryRepository,
) AssetService {
return &assetService{
assetRepo: assetRepo,
mintOrderRepo: mintOrderRepo,
assetLikeRepo: assetLikeRepo,
userClient: userClient,
db: db,
registryRepo: registryRepo,
}
}
// GetMyAssets 获取我的藏品列表分组格式与星册home一致
func (s *assetService) GetMyAssets(req *pb.GetMyAssetsRequest, userID, starID int64) (*pb.GetMyAssetsResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
// 设置默认分页参数
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
// 2. 查询注册记录(用于获取类型和展示状态)
var registries []*models.AssetRegistry
if s.registryRepo != nil {
regs, err := s.registryRepo.GetByOwner(userID, starID)
if err != nil {
logger.Logger.Error("Failed to get registries",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
// 继续执行,使用空注册列表
registries = []*models.AssetRegistry{}
} else {
registries = regs
}
} else {
registries = []*models.AssetRegistry{}
}
// 3. 创建 assetID -> registry 映射
registryMap := make(map[int64]*models.AssetRegistry)
for _, reg := range registries {
registryMap[reg.AssetID] = reg
}
// 4. 查询资产列表(不分页,获取所有资产用于分组)
allAssets, err := s.assetRepo.GetByOwner(userID, starID, 1000, 0) // 最多获取1000个
if err != nil {
logger.Logger.Error("Failed to get assets",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get assets: %w", err)
}
// 5. 按 type 分组
typeGroups := make(map[string][]*models.AssetRegistry)
for _, reg := range registries {
typeGroups[reg.AssetType] = append(typeGroups[reg.AssetType], reg)
}
// 6. 构建响应分组
groups := make([]*pb.AssetGroup, 0)
// 处理原创藏品 (regular)
if regs, ok := typeGroups[models.AssetTypeRegular]; ok {
group := s.buildRegularGroupForAssets(allAssets, registryMap, regs)
if group != nil {
groups = append(groups, group)
}
}
// 处理典藏藏品 (collection)
if regs, ok := typeGroups[models.AssetTypeCollection]; ok {
group := s.buildCollectionGroupForAssets(allAssets, registryMap, regs)
if group != nil {
groups = append(groups, group)
}
}
// 处理活动藏品 (activity)
if regs, ok := typeGroups[models.AssetTypeActivity]; ok {
group := s.buildActivityGroupForAssets(allAssets, registryMap, regs)
if group != nil {
groups = append(groups, group)
}
}
// 7. 查询总数
total, err := s.assetRepo.CountByOwner(userID, starID)
if err != nil {
logger.Logger.Error("Failed to count assets",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
return nil, fmt.Errorf("failed to count assets: %w", err)
}
hasMore := (page * pageSize) < int32(total)
// 8. 构建响应
response := &pb.GetMyAssetsResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Data: &pb.AssetListData{
Groups: groups,
Total: total,
Page: page,
PageSize: pageSize,
HasMore: hasMore,
},
}
logger.Logger.Debug("Get my assets successful",
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Int("asset_count", len(allAssets)),
zap.Int64("total", total),
)
return response, nil
}
// buildRegularGroupForAssets 构建原创藏品分组
func (s *assetService) buildRegularGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
assetMap := make(map[int64]*models.Asset)
for _, asset := range allAssets {
assetMap[asset.ID] = asset
}
// 按 grade 分组
gradeGroups := make(map[int32][]*models.AssetRegistry)
for _, reg := range registries {
if reg.Grade != nil {
gradeGroups[*reg.Grade] = append(gradeGroups[*reg.Grade], reg)
}
}
// 构建 GradeSection
grades := make([]*pb.GradeSection, 0)
for grade, regs := range gradeGroups {
// 构建 items返回所有不限制数量
items := make([]*pb.AssetItem, 0)
for _, reg := range regs {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
item := &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
Category: "castlove",
Grade: grade,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
}
gradeSection := &pb.GradeSection{
Grade: grade,
Items: items,
TotalCount: int32(len(regs)),
HasMore: false, // 返回所有,不限制
}
grades = append(grades, gradeSection)
}
// 按 grade 降序排序
sort.Slice(grades, func(i, j int) bool {
return grades[i].Grade > grades[j].Grade
})
// 计算 total_count
totalCount := int32(0)
for _, g := range grades {
totalCount += g.TotalCount
}
return &pb.AssetGroup{
Type: models.AssetTypeRegular,
Category: "castlove",
CategoryName: "原创",
Grades: grades,
TotalCount: totalCount,
HasMore: false,
}
}
// buildCollectionGroupForAssets 构建典藏藏品分组
func (s *assetService) buildCollectionGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
assetMap := make(map[int64]*models.Asset)
for _, asset := range allAssets {
assetMap[asset.ID] = asset
}
// 构建 items
items := make([]*pb.AssetItem, 0)
for _, reg := range registries {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
category := ""
if reg.CollectionCategory != nil {
category = *reg.CollectionCategory
}
item := &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
Category: category,
Grade: 0,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
}
return &pb.AssetGroup{
Type: models.AssetTypeCollection,
Category: "",
CategoryName: "典藏",
Items: items,
TotalCount: int32(len(registries)),
HasMore: false,
}
}
// buildActivityGroupForAssets 构建活动藏品分组
func (s *assetService) buildActivityGroupForAssets(allAssets []*models.Asset, registryMap map[int64]*models.AssetRegistry, registries []*models.AssetRegistry) *pb.AssetGroup {
// 创建 assetID -> asset 映射
assetMap := make(map[int64]*models.Asset)
for _, asset := range allAssets {
assetMap[asset.ID] = asset
}
// 构建 items
items := make([]*pb.AssetItem, 0)
for _, reg := range registries {
asset := assetMap[reg.AssetID]
if asset == nil {
continue
}
activityType := ""
if reg.ActivityType != nil {
activityType = *reg.ActivityType
}
item := &pb.AssetItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrlSigned: asset.CoverURL,
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
Category: activityType,
Grade: 0,
DisplayStatus: reg.DisplayStatus,
}
items = append(items, item)
}
return &pb.AssetGroup{
Type: models.AssetTypeActivity,
Category: "",
CategoryName: "活动",
Items: items,
TotalCount: int32(len(registries)),
HasMore: false,
}
}
// GetAsset 获取资产详情
func (s *assetService) GetAsset(req *pb.GetAssetRequest, userID, starID int64) (*pb.GetAssetResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
if req.AssetId <= 0 {
logger.Logger.Warn("Invalid asset_id",
zap.Int64("asset_id", req.AssetId),
)
return nil, fmt.Errorf("invalid asset_id: %d", req.AssetId)
}
// 2. 查询资产(验证所有权)
// 首先尝试通过所有者查询(验证是否为资产所有者)
asset, err := s.assetRepo.GetByIDAndOwner(req.AssetId, userID, starID)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
// 所有者验证失败,检查资产是否正在展出中
// 如果正在展出,则允许访问(即使不是所有者)
isExhibiting, checkErr := s.assetRepo.IsExhibiting(req.AssetId)
if checkErr != nil {
logger.Logger.Warn("Failed to check exhibition status",
zap.Int64("asset_id", req.AssetId),
zap.Error(checkErr),
)
}
if isExhibiting {
// 资产正在展出中,通过 ID 查询(不验证所有权)
asset, err = s.assetRepo.GetByID(req.AssetId)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
logger.Logger.Warn("Asset not found",
zap.Int64("asset_id", req.AssetId),
)
return nil, appErrors.ErrAssetNotFound
}
logger.Logger.Error("Failed to get asset by ID",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get asset: %w", err)
}
logger.Logger.Info("Allowing access to exhibited asset",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
} else {
logger.Logger.Warn("Asset not found or access denied",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrAssetAccessDenied
}
} else {
logger.Logger.Error("Failed to get asset",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get asset: %w", err)
}
}
// 3. 获取所有者的昵称和头像在该star下的nickname
var ownerNickname string
var ownerAvatar string
profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID)
if err != nil {
logger.Logger.Warn("Failed to get owner fan profile, using fallback nickname",
zap.Int64("owner_uid", asset.OwnerUID),
zap.Int64("star_id", asset.StarID),
zap.Error(err),
)
// 获取失败时,使用 User{uid} 作为 fallback
ownerNickname = fmt.Sprintf("User%d", asset.OwnerUID)
} else {
ownerNickname = profile.Nickname
ownerAvatar = profile.AvatarUrl
}
// 4. 检查当前用户是否已点赞(需要获取当前展出中的 exhibition_id
exhibitionID, _ := s.assetRepo.GetExhibitingID(asset.ID)
isLiked := false
if exhibitionID > 0 {
isLiked, err = s.assetLikeRepo.Exists(asset.ID, userID, starID, exhibitionID)
if err != nil {
logger.Logger.Warn("Failed to check like status, will return is_liked as false",
zap.Int64("asset_id", asset.ID),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
zap.Error(err),
)
// 检查失败时,默认为未点赞
isLiked = false
}
}
// 5. 从 asset_registry 表获取 display_status
displayStatus, err := s.assetRepo.GetDisplayStatusByAssetID(asset.ID)
if err != nil {
logger.Logger.Warn("Failed to get display status, will return 0",
zap.Int64("asset_id", asset.ID),
zap.Error(err),
)
displayStatus = 0
}
// 6. 计算每小时收益(始终计算,基于当前点赞数)
hourlyEarnings := calculateHourlyEarnings(asset.LikeCount)
// 7. 计算当前展出收益和过期时间(仅展出中时有值)
earnings := int64(0)
exhibitionExpireAt := int64(0)
if displayStatus == 1 {
// 获取展出开始时间,用于计算展出时长
exhibitionStartTime, _ := s.assetRepo.GetExhibitionStartTime(asset.ID)
if exhibitionStartTime == 0 {
exhibitionStartTime = asset.CreatedAt // 兜底
}
// 获取展出过期时间(先获取,用于计算收益)
exhibitionExpireAt, _ = s.assetRepo.GetExhibitionExpireTime(asset.ID)
earnings = calculateRealtimeEarnings(asset.LikeCount, exhibitionStartTime, time.Now().UnixMilli(), exhibitionExpireAt)
}
// 6.5 从 asset_registry 表获取 grade
grade, err := s.assetRepo.GetGradeByAssetID(asset.ID)
if err != nil {
logger.Logger.Warn("Failed to get grade, will return 0",
zap.Int64("asset_id", asset.ID),
zap.Error(err),
)
grade = 0
}
// 7. 构建响应
response := &pb.GetAssetResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "",
Timestamp: time.Now().UnixMilli(),
},
Asset: ModelToProtoAssetDetail(asset, ownerNickname, ownerAvatar, isLiked, displayStatus, earnings, hourlyEarnings, exhibitionExpireAt, grade),
}
logger.Logger.Debug("Get asset successful",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.String("owner_nickname", ownerNickname),
zap.Bool("is_liked", isLiked),
)
return response, nil
}
// GetAssetStatus 查询上链状态
func (s *assetService) GetAssetStatus(req *pb.GetAssetStatusRequest, userID, starID int64) (*pb.GetAssetStatusResponse, error) {
// 1. 参数验证
if !validator.ValidateUserID(userID) {
logger.Logger.Warn("Invalid user_id",
zap.Int64("user_id", userID),
)
return nil, appErrors.ErrInvalidUserID
}
if !validator.ValidateStarID(starID) {
logger.Logger.Warn("Invalid star_id",
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrInvalidStarID
}
if req.AssetId <= 0 {
logger.Logger.Warn("Invalid asset_id",
zap.Int64("asset_id", req.AssetId),
)
return nil, fmt.Errorf("invalid asset_id: %d", req.AssetId)
}
// 2. 查询资产(验证所有权)
asset, err := s.assetRepo.GetByIDAndOwner(req.AssetId, userID, starID)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
logger.Logger.Warn("Asset not found or access denied",
zap.Int64("asset_id", req.AssetId),
zap.Int64("user_id", userID),
zap.Int64("star_id", starID),
)
return nil, appErrors.ErrAssetAccessDenied
}
logger.Logger.Error("Failed to get asset",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return nil, fmt.Errorf("failed to get asset: %w", err)
}
// 3. 构建响应
response := &pb.GetAssetStatusResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AssetId: asset.ID,
Status: getStatusString(asset.Status),
TxHash: getStringValue(asset.TxHash),
BlockNumber: getInt64Value(asset.BlockNumber),
MintedAt: getInt64Value(asset.MintedAt),
}
logger.Logger.Debug("Get asset status successful",
zap.Int64("asset_id", req.AssetId),
zap.Int32("status", asset.Status),
)
return response, nil
}
// GetAssetForRPC 获取资产信息内部RPC调用供Social Service使用
func (s *assetService) GetAssetForRPC(req *pb.GetAssetForRPCRequest) (*pb.GetAssetForRPCResponse, error) {
// 1. 参数验证
if req.AssetId <= 0 {
logger.Logger.Warn("Invalid asset_id in RPC call",
zap.Int64("asset_id", req.AssetId),
)
return &pb.GetAssetForRPCResponse{
Base: appErrors.BuildBaseResponse(fmt.Errorf("invalid asset_id")),
}, nil
}
// 2. 查询资产
asset, err := s.assetRepo.GetByID(req.AssetId)
if err != nil {
if errors.Is(err, appErrors.ErrAssetNotFound) {
logger.Logger.Warn("Asset not found in RPC call",
zap.Int64("asset_id", req.AssetId),
)
return &pb.GetAssetForRPCResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrAssetNotFound),
}, nil
}
logger.Logger.Error("Failed to get asset in RPC call",
zap.Int64("asset_id", req.AssetId),
zap.Error(err),
)
return &pb.GetAssetForRPCResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
// 3. 构建响应
response := &pb.GetAssetForRPCResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "",
Timestamp: time.Now().UnixMilli(),
},
AssetId: asset.ID,
OwnerUid: asset.OwnerUID,
StarId: asset.StarID,
}
logger.Logger.Debug("GetAssetForRPC successful",
zap.Int64("asset_id", req.AssetId),
)
return response, nil
}
// ========== 辅助函数 ==========
// ModelToProtoAsset 将数据库模型转换为Proto格式AssetListItem
func ModelToProtoAsset(asset *models.Asset) *pb.AssetListItem {
if asset == nil {
return nil
}
return &pb.AssetListItem{
AssetId: asset.ID,
Name: asset.Name,
CoverUrl: asset.CoverURL,
Status: getStatusString(asset.Status),
TxHash: getStringValue(asset.TxHash),
CreatedAt: asset.CreatedAt,
MintedAt: getInt64Value(asset.MintedAt),
LikeCount: asset.LikeCount,
}
}
// ModelToProtoAssetDetail 将数据库模型转换为Proto格式Asset详情
func ModelToProtoAssetDetail(asset *models.Asset, ownerNickname string, ownerAvatar string, isLiked bool, displayStatus int32, earnings int64, hourlyEarnings float64, exhibitionExpireAt int64, grade int32) *pb.Asset {
if asset == nil {
return nil
}
// 构建持有者信息
var ownerInfo *pb.OwnerInfo
if ownerAvatar != "" || ownerNickname != "" {
ownerInfo = &pb.OwnerInfo{
UserId: asset.OwnerUID,
Nickname: ownerNickname,
Avatar: ownerAvatar,
}
}
return &pb.Asset{
AssetId: asset.ID,
OwnerUid: asset.OwnerUID,
StarId: asset.StarID,
Name: asset.Name,
CoverUrl: asset.CoverURL,
MaterialUrl: getStringValue(asset.MaterialURL),
Description: getStringValue(asset.Description),
Grade: grade,
Tags: []string(asset.Tags),
Visibility: asset.Visibility,
Status: asset.Status,
TxHash: getStringValue(asset.TxHash),
BlockNumber: getInt64Value(asset.BlockNumber),
LikeCount: asset.LikeCount,
CreatedAt: asset.CreatedAt,
UpdatedAt: asset.UpdatedAt,
MintedAt: getInt64Value(asset.MintedAt),
Owner: ownerInfo,
OwnerNickname: ownerNickname,
IsLiked: isLiked,
Info: asset.Info,
DisplayStatus: displayStatus,
Earnings: earnings,
HourlyEarnings: hourlyEarnings,
ExhibitionExpireAt: exhibitionExpireAt,
}
}
// getStringValue 获取字符串指针的值
func getStringValue(ptr *string) string {
if ptr == nil {
return ""
}
return *ptr
}
// getInt32Value 获取int32指针的值
func getInt32Value(ptr *int32) int32 {
if ptr == nil {
return 0
}
return *ptr
}
// getInt64Value 获取int64指针的值
func getInt64Value(ptr *int64) int64 {
if ptr == nil {
return 0
}
return *ptr
}
// getStatusString 将状态码转换为字符串
func getStatusString(status int32) string {
switch status {
case models.AssetStatusPending:
return "pending"
case models.AssetStatusActive:
return "minted"
default:
return "unknown"
}
}
// calculateHourlyEarnings 计算每小时收益
// 公式R0 × [100% + Buff(n)]
// R0 = 5 水晶/小时Buff(n) 根据点赞数计算
func calculateHourlyEarnings(likeCount int32) float64 {
R0 := float64(5) // 水晶/小时
// 计算Buff
var buff int
switch {
case likeCount >= 30:
buff = 30
case likeCount >= 10:
buff = 20
case likeCount >= 5:
buff = 10
default:
buff = 0
}
// 应用Buff加成R1 = R0 × (100% + Buff)
return R0 * (100 + float64(buff)) / 100
}
// calculateRealtimeEarnings 实时计算展示收益
// 公式R1 = R0 × T × [100% + Buff(n)]
// R0 = 5 水晶/小时T = 上架时长小时Buff(n) 根据点赞数计算
// 注意:使用 min(now, expireAt) 确保过期后收益不再增长
func calculateRealtimeEarnings(likeCount int32, startTime, now, expireAt int64) int64 {
// 计算有效截止时间(展览结束时间 vs 当前时间,取较小值)
endTime := now
if expireAt > 0 && expireAt < now {
endTime = expireAt
}
// 计算上架时长(毫秒转小时)
T := (endTime - startTime) / 3600000
if T <= 0 {
T = 1 // 最少1小时
}
// 总收益 = 每小时收益 × 时长转int64取整
return int64(calculateHourlyEarnings(likeCount) * float64(T))
}