topfans/backend/services/galleryService/service/gallery_service.go
zerosaturation 61921372fb feat: 实现灵感瀑布流双向滚动 Redis 缓存
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-14 18:23:21 +08:00

490 lines
14 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"
"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
}