diff --git a/backend/services/galleryService/service/gallery_service.go b/backend/services/galleryService/service/gallery_service.go new file mode 100644 index 0000000..0e35a0f --- /dev/null +++ b/backend/services/galleryService/service/gallery_service.go @@ -0,0 +1,489 @@ +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 +}