package service import ( "context" "errors" "time" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/gallery" pbCommon "github.com/topfans/backend/pkg/proto/common" "github.com/topfans/backend/services/galleryService/client" "github.com/topfans/backend/services/galleryService/config" "github.com/topfans/backend/services/galleryService/repository" "go.uber.org/zap" ) // ExhibitionService 展品服务接口 type ExhibitionService interface { PlaceAsset(userID, starID int64, req *pb.PlaceAssetRequest) (*pb.PlaceAssetData, error) // RemoveFromSlot 从展位移除资产(统一接口,支持展位所有者踢走和占位者下架) RemoveFromSlot(userID, starID int64, req *pb.RemoveFromSlotRequest) error // GetMyExhibitedAssets 获取我展出的作品列表 GetMyExhibitedAssets(ctx context.Context, userID, starID int64, req *pb.GetMyExhibitedAssetsRequest) (*pb.GetMyExhibitedAssetsResponse, error) // GetUserExhibitedAssets 获取他人展出的作品列表 GetUserExhibitedAssets(ctx context.Context, userID, starID int64, req *pb.GetUserExhibitedAssetsRequest) (*pb.GetUserExhibitedAssetsResponse, error) // RemoveExhibitionByAsset 根据资产ID移除展品(内部RPC,用于领取收益后下架) RemoveExhibitionByAsset(ctx context.Context, assetID int64) error } // exhibitionService 展品服务实现 type exhibitionService struct { repo repository.GalleryRepository assetClient client.AssetRPCClient } // NewExhibitionService 创建展品服务实例 func NewExhibitionService(repo repository.GalleryRepository, assetClient client.AssetRPCClient) ExhibitionService { return &exhibitionService{ repo: repo, assetClient: assetClient, } } // PlaceAsset 在展位展示藏品(支持放置到他人展馆,方案A实现) func (s *exhibitionService) PlaceAsset(userID, starID int64, req *pb.PlaceAssetRequest) (*pb.PlaceAssetData, error) { // 1. 验证资产是否存在且属于当前用户 (RPC Asset Service) if s.assetClient == nil { return nil, errors.New("asset client not initialized") } asset, err := s.assetClient.GetAssetForRPC(req.AssetId, userID, starID) if err != nil { // GetAssetForRPC 已经包含了详细的错误信息,直接返回 return nil, err } if asset == nil { return nil, errors.New("资产信息为空") } // 验证资产所有权(双重验证,确保安全) if asset.OwnerUid != userID { return nil, errors.New("资产不属于当前用户") } // 2. 获取目标展位信息 slot, err := s.repo.GetSlotByID(req.SlotId) if err != nil { return nil, err } if !slot.IsEnabled { return nil, errors.New("展位未解锁") } // 3. 检查展位是否已被占用(使用时间判断) now := time.Now().UnixMilli() existingExhibition, err := s.repo.GetActiveExhibitionBySlot(req.SlotId, now) if err != nil { return nil, err } if existingExhibition != nil { return nil, errors.New("展位已被占用") } // 4. 校验权限与可见性(支持放置到他人展馆) isOwner := slot.UserID == userID if !isOwner { // 如果不是自己的展位,必须是 public 且在同一个明星下 if slot.Visibility != "public" { return nil, errors.New("该展位是私有的,无法放置展品") } if slot.StarID != starID { return nil, errors.New("只能在同一明星的展馆中放置展品") } } // 5. 执行放置逻辑 expireAt := now + config.GetExhibitionDuration()*1000 exhibition := &models.Exhibition{ AssetID: req.AssetId, SlotID: req.SlotId, HostProfileID: slot.HostProfileID, OccupierUID: userID, OccupierStarID: starID, StartTime: now, ExpireAt: expireAt, CreatedAt: now, UpdatedAt: now, } // 6. 事务性创建展品并更新展示状态(原子操作) if err := s.repo.PlaceExhibitionTx(exhibition, 1); err != nil { return nil, err } // 7. 发布事件(不再强依赖同步更新 Asset Service 状态) // TODO: 实现事件发布逻辑 // go s.publishEvent("gallery.exhibit", exhibition) // 9. 构造响应 return &pb.PlaceAssetData{ Status: "OCCUPIED", OccupiedUntil: time.UnixMilli(expireAt).Format(time.RFC3339), OccupierUid: userID, }, nil } // RemoveFromSlot 从展位移除资产(统一接口,支持展位所有者踢走和占位者下架) func (s *exhibitionService) RemoveFromSlot(userID, starID int64, req *pb.RemoveFromSlotRequest) error { // 1. 查询展位信息 slot, err := s.repo.GetSlotByID(req.SlotId) if err != nil { return err } // 2. 查询展品展示记录 exhibition, err := s.repo.GetExhibitionBySlot(req.SlotId) if err != nil { return err } if exhibition == nil { return errors.New("展位当前没有展品") } // 3. 验证权限(展位所有者或占位者都可以移除) isSlotOwner := slot.UserID == userID && slot.StarID == starID isOccupier := exhibition.OccupierUID == userID && exhibition.OccupierStarID == starID if !isSlotOwner && !isOccupier { return errors.New("无权移除资产,只有展位所有者或占位者可以操作") } // 4. 事务性删除展品并更新展示状态(原子操作) if err := s.repo.RemoveExhibitionTx(exhibition.ID, exhibition.AssetID); err != nil { return err } // 5. 清除该资产的点赞记录(不阻断主流程) // 目的:允许同一用户在下次展出时可以再次点赞 go func(assetID int64) { if err := s.assetClient.ClearAssetLikeRecords(assetID); err != nil { logger.Logger.Error("Failed to clear asset like records after removal", zap.Int64("asset_id", assetID), zap.Error(err), ) } }(exhibition.AssetID) // 6. 发布事件 // TODO: 实现事件发布逻辑 // go s.publishEvent("gallery.remove_from_slot", exhibition) return nil } // GetMyExhibitedAssets 获取我展出的作品列表 func (s *exhibitionService) GetMyExhibitedAssets(ctx context.Context, userID, starID int64, req *pb.GetMyExhibitedAssetsRequest) (*pb.GetMyExhibitedAssetsResponse, error) { page := req.Page if page <= 0 { page = 1 } pageSize := req.PageSize if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } items, total, err := s.repo.GetMyExhibitedAssets(userID, starID, int(page), int(pageSize)) if err != nil { logger.Logger.Error("Failed to get my exhibited assets", zap.Error(err), zap.Int64("user_id", userID), zap.Int64("star_id", starID), ) return &pb.GetMyExhibitedAssetsResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR, Message: "Failed to get my exhibited assets: " + err.Error(), Timestamp: time.Now().UnixMilli(), }, }, nil } // 转换为 proto 类型 pbItems := make([]*pb.ExhibitedAssetItem, 0, len(items)) for _, item := range items { pbItems = append(pbItems, &pb.ExhibitedAssetItem{ AssetId: item.AssetID, Name: item.Name, CoverUrl: item.CoverURL, LikeCount: item.LikeCount, ExhibitedAt: item.ExhibitedAt, ExpireAt: item.ExpireAt, Earnings: item.Earnings, }) } hasMore := int64(page)*int64(pageSize) < total return &pb.GetMyExhibitedAssetsResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_OK, Message: "success", Timestamp: time.Now().UnixMilli(), }, Data: &pb.ExhibitedAssetsData{ Items: pbItems, Page: page, PageSize: pageSize, Total: total, HasMore: hasMore, }, }, nil } // GetUserExhibitedAssets 获取他人展出的作品列表 func (s *exhibitionService) GetUserExhibitedAssets(ctx context.Context, userID, starID int64, req *pb.GetUserExhibitedAssetsRequest) (*pb.GetUserExhibitedAssetsResponse, error) { page := req.Page if page <= 0 { page = 1 } pageSize := req.PageSize if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } items, total, err := s.repo.GetUserExhibitedAssets(userID, starID, int(page), int(pageSize)) if err != nil { logger.Logger.Error("Failed to get user exhibited assets", zap.Error(err), zap.Int64("user_id", userID), zap.Int64("star_id", starID), ) return &pb.GetUserExhibitedAssetsResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_INTERNAL_ERROR, Message: "Failed to get user exhibited assets: " + err.Error(), Timestamp: time.Now().UnixMilli(), }, }, nil } pbItems := make([]*pb.ExhibitedAssetItem, 0, len(items)) for _, item := range items { pbItems = append(pbItems, &pb.ExhibitedAssetItem{ AssetId: item.AssetID, Name: item.Name, CoverUrl: item.CoverURL, LikeCount: item.LikeCount, ExhibitedAt: item.ExhibitedAt, ExpireAt: item.ExpireAt, Earnings: item.Earnings, }) } hasMore := int64(page)*int64(pageSize) < total return &pb.GetUserExhibitedAssetsResponse{ Base: &pbCommon.BaseResponse{ Code: pbCommon.StatusCode_STATUS_OK, Message: "success", Timestamp: time.Now().UnixMilli(), }, Data: &pb.ExhibitedAssetsData{ Items: pbItems, Page: page, PageSize: pageSize, Total: total, HasMore: hasMore, }, }, nil } // RemoveExhibitionByAsset 根据资产ID移除展品(内部RPC,用于领取收益后下架) func (s *exhibitionService) RemoveExhibitionByAsset(ctx context.Context, assetID int64) error { // 1. 查询展品展示记录 exhibition, err := s.repo.GetExhibitionByAsset(assetID) if err != nil { return err } if exhibition == nil { return errors.New("资产当前没有展出记录") } // 2. 事务性删除展品并更新展示状态(原子操作) if err := s.repo.RemoveExhibitionTx(exhibition.ID, exhibition.AssetID); err != nil { return err } // 3. 清除该资产的点赞记录(不阻断主流程) go func(assetID int64) { if err := s.assetClient.ClearAssetLikeRecords(assetID); err != nil { logger.Logger.Error("Failed to clear asset like records after removal", zap.Int64("asset_id", assetID), zap.Error(err), ) } }(exhibition.AssetID) return nil }