feat: 实现灵感瀑布流双向滚动 Redis 缓存

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zerosaturation 2026-05-14 18:23:21 +08:00
parent 7574d38e96
commit 61921372fb

View File

@ -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
}