feat: 实现灵感瀑布流双向滚动 Redis 缓存
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7574d38e96
commit
61921372fb
489
backend/services/galleryService/service/gallery_service.go
Normal file
489
backend/services/galleryService/service/gallery_service.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user