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