package service import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "time" "github.com/topfans/backend/pkg/database" "github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/models" pb "github.com/topfans/backend/pkg/proto/gallery" "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" ) // GalleryService 展馆服务接口 type GalleryService interface { GetMyGallery(userID, starID int64) (*pb.GalleryData, error) GetUserGallery(userID, starID, targetUID int64) (*pb.GalleryData, error) GetInspirationFlow(userID, starID int64, cursor, direction string, limit int32, materialType, sessionID string) (*pb.InspirationFlowData, string, error) } // galleryService 展馆服务实现 type galleryService struct { repo repository.GalleryRepository assetClient client.AssetRPCClient userClient client.UserRPCClient } // NewGalleryService 创建展馆服务实例 func NewGalleryService(repo repository.GalleryRepository, assetClient client.AssetRPCClient, userClient client.UserRPCClient) GalleryService { return &galleryService{ repo: repo, assetClient: assetClient, userClient: userClient, } } // GetMyGallery 获取我的展馆(包含懒加载逻辑) func (s *galleryService) GetMyGallery(userID, starID int64) (*pb.GalleryData, error) { // 1. 获取粉丝档案信息(包含真实ID用于外键) profile, err := s.userClient.GetFanProfile(userID, starID) if err != nil { logger.Logger.Warn("Failed to get fan profile", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err), ) return nil, err } hostProfileID := profile.ID // 2. 查询展位列表 slots, err := s.repo.GetSlotsByUser(userID, starID) if err != nil { return nil, err } // 3. 如果不存在展位,懒加载创建初始展位 if len(slots) == 0 { if err := s.repo.CreateInitialSlots(userID, starID, hostProfileID); err != nil { return nil, err } // 重新查询 slots, err = s.repo.GetSlotsByUser(userID, starID) if err != nil { return nil, err } } // 4. 填充展位信息和展品信息 slotInfos, err := s.buildSlotInfos(slots, userID, starID, true) if err != nil { return nil, err } // 5. 构造响应(使用 userID 而不是 hostProfileID,以便前端正确判断是否是自己的展馆) galleryData := &pb.GalleryData{ GalleryOwnerId: userID, SlotTotal: int32(len(slots)), Slots: slotInfos, Nickname: profile.Nickname, } return galleryData, nil } // GetUserGallery 获取他人展馆 func (s *galleryService) GetUserGallery(userID, starID, targetUID int64) (*pb.GalleryData, error) { // 1. 获取目标用户的粉丝档案信息(包含真实ID用于外键) profile, err := s.userClient.GetFanProfile(targetUID, starID) if err != nil { logger.Logger.Warn("Failed to get fan profile", zap.Int64("target_uid", targetUID), zap.Int64("star_id", starID), zap.Error(err), ) return nil, err } hostProfileID := profile.ID // 2. 查询目标用户的展位列表 slots, err := s.repo.GetSlotsByUser(targetUID, starID) if err != nil { return nil, err } // 3. 如果不存在展位:为目标用户创建初始展位(访问他人展馆也应返回真实展馆结构) if len(slots) == 0 { if err := s.repo.CreateInitialSlots(targetUID, starID, hostProfileID); err != nil { return nil, err } // 重新查询 slots, err = s.repo.GetSlotsByUser(targetUID, starID) if err != nil { return nil, err } } // 4. 填充展位信息和展品信息 slotInfos, err := s.buildSlotInfos(slots, userID, starID, false) if err != nil { return nil, err } // 5. 构造响应(使用 targetUID 而不是 hostProfileID,保持与 GetMyGallery 一致) galleryData := &pb.GalleryData{ GalleryOwnerId: targetUID, SlotTotal: int32(len(slots)), Slots: slotInfos, Nickname: profile.Nickname, } return galleryData, nil } // buildSlotInfos 构建展位信息列表 func (s *galleryService) buildSlotInfos(slots []*models.BoothSlot, viewerUID, viewerStarID int64, isOwner bool) ([]*pb.SlotInfo, error) { slotInfos := make([]*pb.SlotInfo, 0, len(slots)) for _, slot := range slots { slotInfo := &pb.SlotInfo{ SlotId: slot.SlotID, SlotIndex: int32(slot.SlotIndex), IsEnabled: slot.IsEnabled, Visibility: slot.Visibility, } var exhibition *models.Exhibition // 确定展位状态 if !slot.IsEnabled { // 未解锁 slotInfo.Status = "LOCKED" // 添加解锁条件 slotInfo.UnlockCondition = s.getUnlockCondition(slot.SlotIndex) } else { // 已解锁,查询是否有展品 var err error exhibition, err = s.repo.GetExhibitionBySlot(slot.SlotID) if err != nil { return nil, err } if exhibition == nil { // 空展位 slotInfo.Status = "EMPTY" } else { // 已占用 slotInfo.Status = "OCCUPIED" slotInfo.OccupierUid = exhibition.OccupierUID slotInfo.OccupiedAt = exhibition.StartTime slotInfo.ExpireAt = exhibition.ExpireAt // 填充资产信息(从Asset Service获取) // 使用资产的真正所有者(occupier_uid)来查询资产信息 // 因为 Asset Service 的 GetAsset 需要验证所有权 assetInfo, err := s.getAssetInfo(exhibition.AssetID, exhibition.OccupierUID, exhibition.OccupierStarID) if err != nil { // 如果获取资产信息失败,记录日志但不中断流程 logger.Logger.Warn("Failed to get asset info for exhibition", zap.Int64("asset_id", exhibition.AssetID), zap.Int64("slot_id", slot.SlotID), zap.Int64("occupier_uid", exhibition.OccupierUID), zap.Int64("occupier_star_id", exhibition.OccupierStarID), zap.Error(err), ) slotInfo.Asset = &pb.AssetInfo{ AssetId: exhibition.AssetID, Name: "未知资产", } } else { slotInfo.Asset = assetInfo // 计算剩余时间 now := time.Now().UnixMilli() remainTime := (exhibition.ExpireAt - now) / 1000 // 转换为秒 if remainTime < 0 { remainTime = 0 } slotInfo.Asset.RemainTime = remainTime } } } // 计算操作权限 slotInfo.CanOperate, slotInfo.Operation = s.calculateOperation(slot, exhibition, viewerUID, isOwner) slotInfos = append(slotInfos, slotInfo) } return slotInfos, nil } // calculateOperation 计算展位操作权限 // 返回 (canOperate bool, operation string) // operation 取值: "place" | "remove" | "none" func (s *galleryService) calculateOperation(slot *models.BoothSlot, exhibition *models.Exhibition, viewerUID int64, isOwner bool) (bool, string) { logger.Logger.Info("=== calculateOperation DEBUG ===", zap.Int64("slot_id", slot.SlotID), zap.String("visibility", slot.Visibility), zap.Bool("is_owner", isOwner), zap.Int64("viewer_uid", viewerUID), ) if exhibition != nil { logger.Logger.Info("=== exhibition info ===", zap.Int64("exhibition_id", exhibition.ID), zap.Int64("occupier_uid", exhibition.OccupierUID), ) } // 未解锁的展位不能操作 if !slot.IsEnabled { return false, "none" } // 私有展位 (我的/他的展位) if slot.Visibility == "private" { // 所有者访问自己展馆 if isOwner { if exhibition == nil { return true, "place" // 空展位,可以放置 } // 有藏品时,可以主动结束展览 return true, "remove" } // 访问别人展馆 - 不能操作 return false, "none" } // 公共展位 (共享展位) if slot.Visibility == "public" { if exhibition == nil { // 空展位 - 只有访问别人展馆时可以放置 if !isOwner { return true, "place" } return false, "none" // 自己的展馆空展位不能操作 } // 有藏品时 if exhibition.OccupierUID == viewerUID { return true, "remove" // 有自己的藏品,可以主动结束展览 } // 有别人的藏品 - 只有所有者可以踢出 if isOwner { return true, "remove" } return false, "none" } return false, "none" } // getUnlockCondition 获取解锁条件 func (s *galleryService) getUnlockCondition(slotIndex int) *pb.UnlockCondition { // 从配置中获取解锁条件 requiredLevel, hasLevel := config.GalleryRules.UnlockLevelBySlot[slotIndex] requiredCrystal, hasCrystal := config.GalleryRules.UnlockCrystalBySlot[slotIndex] // 优先等级解锁 if hasLevel { return &pb.UnlockCondition{ Type: "level", Value: int32(requiredLevel), } } // 如果没有等级要求,返回水晶购买 if hasCrystal { return &pb.UnlockCondition{ Type: "crystal", Value: int32(requiredCrystal), } } // 如果都没有配置,返回nil return nil } // getAssetInfo 获取资产信息(从Asset Service) func (s *galleryService) getAssetInfo(assetID, userID, starID int64) (*pb.AssetInfo, error) { if s.assetClient == nil { return nil, errors.New("asset client not initialized") } // 调用Asset Service RPC获取资产信息(传递用户信息用于权限验证) asset, err := s.assetClient.GetAssetInfo(assetID, userID, starID) if err != nil { return nil, err } return &pb.AssetInfo{ AssetId: asset.AssetId, Name: asset.Name, CoverUrl: asset.CoverUrl, LikeCount: asset.LikeCount, }, nil } // ==================== 辅助函数 ==================== // generateHostProfileID 生成 host_profile_id func generateHostProfileID(userID, starID int64) int64 { return userID*1000000 + starID } // ==================== 灵感瀑布相关 ==================== // GetInspirationFlow 获取灵感瀑布藏品列表 func (s *galleryService) GetInspirationFlow(userID, starID int64, cursor, direction string, limit int32, materialType, sessionID string) (*pb.InspirationFlowData, string, error) { // 默认值处理 if limit <= 0 { limit = 10 } if limit > 20 { limit = 20 } if direction == "" { direction = "right" } if materialType == "" { materialType = "all" } // 解析游标获取limit decodedLimit := int(limit) if cursor != "" { decoded, err := base64.StdEncoding.DecodeString(cursor) if err == nil { var cursorData map[string]int if json.Unmarshal(decoded, &cursorData) == nil { if l, ok := cursorData["limit"]; ok { decodedLimit = l } } } } // 向右滚动:从仓库随机查询新数据(排除已展示) if direction == "right" { // Get excludeIDs from cache var excludeIDs []int64 if sessionID != "" { cache, err := database.GetInspirationFlowCache(context.Background(), starID, sessionID) if err == nil && cache != nil { excludeIDs = cache.DisplayedIDs } } items, err := s.repo.GetRandomExhibitions(starID, materialType, excludeIDs, int(limit), 0) if err != nil { logger.Logger.Warn("GetInspirationFlow failed", zap.Int64("star_id", starID), zap.String("direction", direction), zap.Error(err), ) return nil, "", err } // 转换为pb并添加到缓存 pbItems := make([]*pb.InspirationFlowItem, 0, len(items)) for _, item := range items { pbItems = append(pbItems, &pb.InspirationFlowItem{ AssetId: item.AssetID, Name: item.Name, CoverUrl: item.CoverURL, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, Span: item.Span, MaterialType: item.MaterialType, }) // Add items to cache if sessionID != "" { cacheEntry := database.InspirationFlowCacheEntry{ AssetID: item.AssetID, Name: item.Name, CoverURL: item.CoverURL, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, Span: item.Span, MaterialType: item.MaterialType, } _ = database.AddToInspirationFlowCache(context.Background(), starID, sessionID, cacheEntry, 30*time.Minute) } } // 生成新游标 newCursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, decodedLimit))) // 检查是否还有更多数据 total, err := s.repo.CountValidExhibitions(starID, materialType) hasMore := int64(len(items)) < total return &pb.InspirationFlowData{ Items: pbItems, Cursor: newCursor, HasMore: hasMore, SessionId: sessionID, }, sessionID, nil } // 向左滚动:从缓存获取历史数据 if sessionID == "" { return &pb.InspirationFlowData{ Items: []*pb.InspirationFlowItem{}, Cursor: cursor, HasMore: false, SessionId: sessionID, }, sessionID, nil } cache, err := database.GetInspirationFlowCache(context.Background(), starID, sessionID) if err != nil || cache == nil || len(cache.DisplayedIDs) == 0 { return &pb.InspirationFlowData{ Items: []*pb.InspirationFlowItem{}, Cursor: cursor, HasMore: false, SessionId: sessionID, }, sessionID, nil } // Parse offset from cursor offset := 0 if cursor != "" { decoded, err := base64.StdEncoding.DecodeString(cursor) if err == nil { var cursorData map[string]interface{} if json.Unmarshal(decoded, &cursorData) == nil { if o, ok := cursorData["offset"].(float64); ok { offset = int(o) } } } } historyItems := database.GetHistoryPage(cache, offset, int(limit)) hasMore := offset+len(historyItems) < len(cache.DisplayedIDs) pbItems := make([]*pb.InspirationFlowItem, 0, len(historyItems)) for _, item := range historyItems { pbItems = append(pbItems, &pb.InspirationFlowItem{ AssetId: item.AssetID, Name: item.Name, CoverUrl: item.CoverURL, LikeCount: item.LikeCount, OwnerNickname: item.OwnerNickname, Span: item.Span, MaterialType: item.MaterialType, }) } newCursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d,"offset":%d}`, decodedLimit, offset+len(historyItems)))) return &pb.InspirationFlowData{ Items: pbItems, Cursor: newCursor, HasMore: hasMore, SessionId: sessionID, }, sessionID, nil }