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 profile, err := s.userClient.GetFanProfile(context.Background(), asset.OwnerUID, asset.StarID) if err != nil { logger.Logger.Warn("Failed to get owner fan profile, will return without nickname", zap.Int64("owner_uid", asset.OwnerUID), zap.Int64("star_id", asset.StarID), zap.Error(err), ) // 获取失败时,使用空字符串 ownerNickname = "" } else { ownerNickname = profile.Nickname } // 4. 检查当前用户是否已点赞 isLiked, err := s.assetLikeRepo.Exists(asset.ID, userID, starID) 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 // 兜底 } earnings = calculateRealtimeEarnings(asset.LikeCount, exhibitionStartTime, time.Now().UnixMilli()) // 获取展出过期时间 exhibitionExpireAt, _ = s.assetRepo.GetExhibitionExpireTime(asset.ID) } // 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, 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, isLiked bool, displayStatus int32, earnings int64, hourlyEarnings float64, exhibitionExpireAt int64, grade int32) *pb.Asset { if asset == nil { return nil } 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: nil, // 保留用于兼容性 OwnerNickname: ownerNickname, // 持有者昵称 IsLiked: isLiked, // 当前用户是否已点赞 Info: asset.Info, DisplayStatus: displayStatus, // 展示状态:0=待展示, 1=已展示 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) 根据点赞数计算 func calculateRealtimeEarnings(likeCount int32, startTime, now int64) int64 { // 计算上架时长(毫秒转小时) T := (now - startTime) / 3600000 if T <= 0 { T = 1 // 最少1小时 } // 总收益 = 每小时收益 × 时长(转int64取整) return int64(calculateHourlyEarnings(likeCount) * float64(T)) }