topfans/docs/superpowers/plans/2026-05-27-热门推荐模块实现.md

35 KiB
Raw Blame History

热门推荐模块实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现热门推荐模块后端接口,包括批量获取、单个刷新、查看更多分页,以及灵感瀑布流逻辑调整

Architecture: 后端采用网关+Dubbo RPC架构backend/gateway + backend/services/galleryService)。新增 /inspiration-flow/hot/batch/inspiration-flow/hot/inspiration-flow/hot/more 三个接口,灵感瀑布 /inspiration-flow 逻辑调整为分段填充(点赞>平均值优先)

Tech Stack: Go / Gin / GORM / Redis / Dubbo RPC


文件结构

文件 改动
backend/proto/gallery.proto 新增 Proto 消息定义
backend/pkg/proto/gallery/gallery.pb.go 重新生成 Proto 代码
backend/gateway/controller/gallery_controller.go 新增 GetHotInspirationFlowBatchGetHotInspirationFlowGetHotInspirationFlowMore 方法
backend/gateway/router/router.go 新增 /inspiration-flow/hot/* 路由注册
backend/gateway/dto/gallery_dto.go 新增 DTO 结构体
backend/gateway/dto/gallery_converter.go 新增 DTO 转换函数
backend/services/galleryService/service/gallery_service.go 新增 GetHotInspirationFlowBatchGetHotInspirationFlowGetHotInspirationFlowMore Service 方法
backend/services/galleryService/repository/gallery_repository.go 新增 Repository 方法
backend/pkg/database/redis.go 新增热门推荐缓存辅助函数

Task 1: 更新 Proto 文件

Files:

  • Modify: backend/proto/gallery.proto

  • Step 1: 添加新的 Proto 消息

backend/proto/gallery.proto 末尾添加:

// 空请求(用于批量接口)
message GetEmpty {}

// 热门类型请求
message GetHotTypeRequest {
  string type = 1;  // hot_recommend, hot_star_card, hot_badge, hot_poster
}

// 热门分类条目
message HotCategoryItem {
  string type = 1;           // 分类类型hot_recommend, hot_star_card, hot_badge, hot_poster
  string title = 2;          // 展示标题:热门推荐、热门星卡、热门吧唧、热门海报
  repeated InspirationFlowItem items = 3;  // 作品列表
}

// 热门批量响应(无参数,返回所有分类)
message GetHotInspirationFlowBatchResponse {
  BaseResponse base = 1;
  repeated HotCategoryItem categories = 2;  // 动态分类列表
}

// 热门单个分类响应
message GetHotInspirationFlowResponse {
  BaseResponse base = 1;
  HotCategoryItem data = 2;
}

// 热门查看更多请求
message GetHotInspirationFlowMoreRequest {
  string type = 1;            // 分类类型
  string cursor = 2;          // 翻页游标Base64 编码的 JSON
  int32 limit = 3;           // 每页数量默认20
}

// 热门查看更多响应
message GetHotInspirationFlowMoreResponse {
  BaseResponse base = 1;
  InspirationFlowData data = 2;
}
  • Step 2: 在 GalleryService 添加新方法声明

service GalleryService 中添加:

// 批量获取热门分类
rpc GetHotInspirationFlowBatch(GetEmpty) returns (GetHotInspirationFlowBatchResponse);

// 单个分类刷新
rpc GetHotInspirationFlow(GetHotTypeRequest) returns (GetHotInspirationFlowResponse);

// 查看更多分页
rpc GetHotInspirationFlowMore(GetHotInspirationFlowMoreRequest) returns (GetHotInspirationFlowMoreResponse);
  • Step 3: 运行 Proto 生成
cd /Users/liulujian/Documents/code/TopFansByGithub/backend
./proto/gen.sh

Task 2: 新增 Redis 缓存辅助函数

Files:

  • Modify: backend/pkg/database/redis.go

  • Step 1: 添加热门推荐缓存常量和结构体

const (
    // ... 现有常量 ...
    HotAvgLikesKeyPrefix = "hot_avg_likes:"  // 热门分类点赞均值缓存
    HotBatchKeyPrefix    = "hot_batch:"      // 热门批量结果缓存
)

// HotAvgLikesCache 点赞均值缓存(用于减少重复计算平均值)
type HotAvgLikesCache struct {
    AvgLikes  float64 `json:"avg_likes"`
    Total     int64   `json:"total"`
    UpdatedAt int64   `json:"updated_at"`
}
  • Step 2: 添加缓存操作函数
// GetHotAvgLikesCache 获取热门分类点赞均值缓存
// 返回缓存的均值和该分类的作品总数,如果缓存不存在则返回 nil
func GetHotAvgLikesCache(ctx context.Context, starID int64, assetType string) (*HotAvgLikesCache, error) {
    if RedisClient == nil {
        return nil, nil
    }
    key := fmt.Sprintf("%s%d:%s", HotAvgLikesKeyPrefix, starID, assetType)
    data, err := RedisClient.Get(ctx, key).Bytes()
    if err != nil {
        if err == redis.Nil {
            return nil, nil
        }
        return nil, err
    }
    var cache HotAvgLikesCache
    if json.Unmarshal(data, &cache) != nil {
        return nil, nil
    }
    return &cache, nil
}

// SetHotAvgLikesCache 设置热门分类点赞均值缓存TTL: 5分钟
func SetHotAvgLikesCache(ctx context.Context, starID int64, assetType string, cache *HotAvgLikesCache) error {
    if RedisClient == nil {
        return nil
    }
    key := fmt.Sprintf("%s%d:%s", HotAvgLikesKeyPrefix, starID, assetType)
    data, err := json.Marshal(cache)
    if err != nil {
        return err
    }
    return RedisClient.Set(ctx, key, data, 5*time.Minute).Err()
}

// GetHotBatchCache 获取热门批量结果缓存
func GetHotBatchCache(ctx context.Context, starID int64) ([]byte, error) {
    if RedisClient == nil {
        return nil, nil
    }
    key := fmt.Sprintf("%s%d", HotBatchKeyPrefix, starID)
    return RedisClient.Get(ctx, key).Bytes()
}

// SetHotBatchCache 设置热门批量结果缓存TTL: 30秒
func SetHotBatchCache(ctx context.Context, starID int64, data []byte) error {
    if RedisClient == nil {
        return nil
    }
    key := fmt.Sprintf("%s%d", HotBatchKeyPrefix, starID)
    return RedisClient.Set(ctx, key, data, 30*time.Second).Err()
}
  • Step 3: 提交
git add pkg/database/redis.go
git commit -m "feat: add redis cache helpers for hot inspiration flow"

Task 3: 新增 Repository 方法

Files:

  • Modify: backend/services/galleryService/repository/gallery_repository.go

  • Step 1: 在 GalleryRepository 接口添加方法声明

// ========== 热门推荐相关 ==========

// CountHotAssetsAboveAvg 统计点赞数高于平均值的作品数量
// assetType: 资产类型star_card/badge/poster空字符串表示所有类型
CountHotAssetsAboveAvg(starID int64, assetType string) (int64, float64, error)

// GetHotAssetsByAvg 获取点赞数>=平均值的作品(基于时间窗口伪随机排序)
// assetType: 资产类型,空字符串表示所有类型(用于 hot_recommend
// limit: 返回数量
GetHotAssetsByAvg(starID int64, assetType string, limit int) ([]*InspirationFlowItem, error)

// GetHotAssetsByAvgWithCursor 获取点赞数>=平均值的作品分页按点赞数DESC + asset_id DESC排序
GetHotAssetsByAvgWithCursor(starID int64, assetType string, cursorLikeCount int64, cursorAssetID int64, limit int) ([]*InspirationFlowItem, int64, error)
  • Step 2: 实现 GetHotAssetsByAvg基于时间窗口伪随机
// GetHotAssetsByAvg 获取点赞数>=平均值的作品(基于时间窗口伪随机排序)
func (r *galleryRepository) GetHotAssetsByAvg(starID int64, assetType string, limit int) ([]*InspirationFlowItem, error) {
    var items []*InspirationFlowItem
    now := time.Now().UnixMilli()

    // 构建基础查询
    baseQuery := r.db.Model(&models.Exhibition{}).
        Where("exhibitions.occupier_star_id = ? AND exhibitions.expire_at > ? AND exhibitions.deleted_at IS NULL", starID, now).
        Joins("JOIN assets a ON a.id = exhibitions.asset_id").
        Where("a.status = 1 AND a.is_active = true")

    if assetType != "" {
        baseQuery = baseQuery.Where("a.asset_type = ?", assetType)
    }

    // 计算平均值
    var avgLikes float64
    err := baseQuery.Select("COALESCE(AVG(a.like_count), 0)").Scan(&avgLikes).Error
    if err != nil {
        return nil, err
    }

    // 使用基于时间窗口的伪随机排序
    // 避免 ORDER BY RANDOM() 在大表上的性能问题
    windowSeed := now / 30000 % 10 // 每30秒变化一次
    err = baseQuery.
        Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count,
                COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, fp.avatar_url as owner_avatar,
                a.material_type, a.created_at`).
        Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id").
        Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id").
        Where("a.like_count >= ?", avgLikes).
        Where("exhibitions.id % 10 = ?", windowSeed).
        Order("exhibitions.id").
        Limit(limit).
        Scan(&items).Error

    if err != nil {
        return nil, err
    }

    // 如果数量不够,补充查询(不加随机过滤)
    if len(items) < limit {
        remaining := limit - len(items)
        var extraItems []*InspirationFlowItem
        extraQuery := r.db.Model(&models.Exhibition{}).
            Where("exhibitions.occupier_star_id = ? AND exhibitions.expire_at > ? AND exhibitions.deleted_at IS NULL", starID, now).
            Joins("JOIN assets a ON a.id = exhibitions.asset_id").
            Where("a.status = 1 AND a.is_active = true").
            Where("a.like_count >= ?", avgLikes).
            Where("exhibitions.id % 10 != ?", windowSeed)

        if assetType != "" {
            extraQuery = extraQuery.Where("a.asset_type = ?", assetType)
        }

        err = extraQuery.
            Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count,
                    COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, fp.avatar_url as owner_avatar,
                    a.material_type, a.created_at`).
            Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id").
            Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id").
            Order("exhibitions.id").
            Limit(remaining).
            Scan(&extraItems).Error

        if err != nil {
            return nil, err
        }
        items = append(items, extraItems...)
    }

    // 填充 Span
    for _, item := range items {
        item.Span = calcSpanByLevel(item.Level)
    }

    return items, nil
}
  • Step 3: 实现 GetHotAssetsByAvgWithCursor查看更多分页用
// GetHotAssetsByAvgWithCursor 获取点赞数>=平均值的作品分页按点赞数DESC + asset_id DESC排序
func (r *galleryRepository) GetHotAssetsByAvgWithCursor(starID int64, assetType string, cursorLikeCount int64, cursorAssetID int64, limit int) ([]*InspirationFlowItem, int64, error) {
    var items []*InspirationFlowItem
    var total int64
    now := time.Now().UnixMilli()

    baseQuery := r.db.Model(&models.Exhibition{}).
        Where("exhibitions.occupier_star_id = ? AND exhibitions.expire_at > ? AND exhibitions.deleted_at IS NULL", starID, now).
        Joins("JOIN assets a ON a.id = exhibitions.asset_id").
        Where("a.status = 1 AND a.is_active = true")

    if assetType != "" {
        baseQuery = baseQuery.Where("a.asset_type = ?", assetType)
    }

    // 统计总数(用于 has_more
    err := baseQuery.Count(&total).Error
    if err != nil {
        return nil, 0, err
    }

    // 先计算该分类的平均值(基于 baseQuery 确保 assetType 过滤生效)
    var avgLikes float64
    err = baseQuery.Select("COALESCE(AVG(a.like_count), 0)").Scan(&avgLikes).Error
    if err != nil {
        return nil, 0, err
    }

    // 应用游标条件
    if cursorLikeCount > 0 || cursorAssetID > 0 {
        baseQuery = baseQuery.Where("(a.like_count, a.id) < (?, ?)", cursorLikeCount, cursorAssetID)
    }

    // 查询数据(点赞数 >= 平均值,按点赞数 DESC + id DESC 排序)
    err = baseQuery.
        Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count,
                COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, fp.avatar_url as owner_avatar,
                a.material_type, a.created_at`).
        Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id").
        Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id").
        Where("a.like_count >= ?", avgLikes).
        Order("a.like_count DESC, a.id DESC").
        Limit(limit).
        Scan(&items).Error

    if err != nil {
        return nil, 0, err
    }

    // 填充 Span
    for _, item := range items {
        item.Span = calcSpanByLevel(item.Level)
    }

    return items, total, nil
}
  • Step 4: 提交
git add services/galleryService/repository/gallery_repository.go
git commit -m "feat: add repository methods for hot inspiration flow"

Task 4: 新增 Service 层方法

Files:

  • Modify: backend/services/galleryService/service/gallery_service.go

  • Step 1: 在 GalleryService 接口添加方法声明

import (
    "context"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "strconv"
    "sync"
    "time"

    "dubbo.apache.org/dubbo-go/v3/common/constant"
    pb "github.com/topfans/backend/pkg/proto/gallery"
    "github.com/topfans/backend/pkg/models"
    "github.com/topfans/backend/pkg/database"
    "github.com/topfans/backend/services/galleryService/repository"
)

type GalleryService interface {
    // ... 现有方法 ...

    // ========== 热门推荐相关 ==========
    GetHotInspirationFlowBatch(ctx context.Context, req *pb.GetEmpty) (*pb.GetHotInspirationFlowBatchResponse, error)
    GetHotInspirationFlow(ctx context.Context, req *pb.GetHotTypeRequest) (*pb.GetHotInspirationFlowResponse, error)
    GetHotInspirationFlowMore(ctx context.Context, req *pb.GetHotInspirationFlowMoreRequest) (*pb.GetHotInspirationFlowMoreResponse, error)
}
  • Step 2: 实现 categoryType 到 assetType 的映射函数
// categoryTypeToAssetType 热门分类 type 转 assetType
func categoryTypeToAssetType(categoryType string) (assetType string, title string) {
    switch categoryType {
    case "hot_star_card":
        return "star_card", "热门星卡"
    case "hot_badge":
        return "badge", "热门吧唧"
    case "hot_poster":
        return "poster", "热门海报"
    default: // hot_recommend
        return "", "热门推荐"
    }
}
  • Step 3: 实现 GetHotInspirationFlowBatch并行查询 + 缓存点赞均值)
// GetHotInspirationFlowBatch 批量获取热门分类(使用 starID 进行缓存分区和查询)
func (s *galleryService) GetHotInspirationFlowBatch(ctx context.Context, req *pb.GetEmpty) (*pb.GetHotInspirationFlowBatchResponse, error) {
    // 从 context 中获取 starIDDubbo 协议通过 attachments 传递)
    starIDStr := ctx.Value(constant.AttachmentKey).(map[string]interface{})["star_id"].(string)
    starID, _ := strconv.ParseInt(starIDStr, 10, 64)

    // 尝试从缓存获取批量结果TTL 30秒
    cached, err := database.GetHotBatchCache(ctx, starID)
    if err == nil && cached != nil && len(cached) > 0 {
        var resp pb.GetHotInspirationFlowBatchResponse
        if json.Unmarshal(cached, &resp) == nil {
            return &resp, nil
        }
    }

    // 缓存未命中,查询数据库
    // 定义4个分类的配置使用 starID 进行缓存分区)
    categories := []struct {
        Type      string
        Title     string
        AssetType string // 空字符串表示所有类型
    }{
        {"hot_recommend", "热门推荐", ""},
        {"hot_star_card", "热门星卡", "star_card"},
        {"hot_badge", "热门吧唧", "badge"},
        {"hot_poster", "热门海报", "poster"},
    }

    // 使用 goroutine 并行查询
    var wg sync.WaitGroup
    mu sync.Mutex
    results := make([]*pb.HotCategoryItem, len(categories))
    errors := make([]error, len(categories))

    for i, cat := range categories {
        wg.Add(1)
        go func(idx int, c struct {
            Type, Title, AssetType string
        }) {
            defer wg.Done()
            items, err := s.getHotItemsByType(ctx, starID, c.AssetType, 8)
            if err != nil {
                errors[idx] = err
                return
            }
            pbItems := s.convertToPbItems(items)
            mu.Lock()
            results[idx] = &pb.HotCategoryItem{
                Type:  c.Type,
                Title: c.Title,
                Items: pbItems,
            }
            mu.Unlock()
        }(i, cat)
    }
    wg.Wait()

    // 检查是否有错误
    for _, err := range errors {
        if err != nil {
            return nil, err
        }
    }

    // 过滤掉空分类
    filtered := make([]*pb.HotCategoryItem, 0, len(results))
    for _, r := range results {
        if r != nil && len(r.Items) > 0 {
            filtered = append(filtered, r)
        }
    }

    resp := &pb.GetHotInspirationFlowBatchResponse{
        Categories: filtered,
    }

    // 更新批量结果缓存TTL 30秒
    if data, err := json.Marshal(resp); err == nil {
        database.SetHotBatchCache(ctx, starID, data)
    }

    return resp, nil
}

// getHotItemsByType 获取热门作品(带缓存点赞均值)
func (s *galleryService) getHotItemsByType(ctx context.Context, starID int64, assetType string, limit int) ([]*InspirationFlowItem, error) {
    // 先尝试从缓存获取点赞均值
    cached, err := database.GetHotAvgLikesCache(ctx, starID, assetType)
    if err == nil && cached != nil && cached.Total > 0 {
        // 缓存命中,查询数据库
        return s.repo.GetHotAssetsByAvg(starID, assetType, limit)
    }

    // 缓存未命中,查询数据库并更新缓存
    items, err := s.repo.GetHotAssetsByAvg(starID, assetType, limit)
    if err != nil {
        return nil, err
    }

    // 更新点赞均值缓存
    if len(items) > 0 {
        total := int64(len(items))
        avgLikes := float64(0)
        for _, item := range items {
            avgLikes += float64(item.LikeCount)
        }
        avgLikes = avgLikes / float64(total)
        database.SetHotAvgLikesCache(ctx, starID, assetType, &database.HotAvgLikesCache{
            AvgLikes:  avgLikes,
            Total:     total,
            UpdatedAt: time.Now().UnixMilli(),
        })
    }

    return items, nil
}
  • Step 4: 实现 GetHotInspirationFlow单个刷新不缓存结果
// GetHotInspirationFlow 单个分类刷新(不缓存结果,用户主动刷新应获取新数据)
func (s *galleryService) GetHotInspirationFlow(ctx context.Context, req *pb.GetHotTypeRequest) (*pb.GetHotInspirationFlowResponse, error) {
    // 从 context 中获取 starID
    starIDStr := ctx.Value(constant.AttachmentKey).(map[string]interface{})["star_id"].(string)
    starID, _ := strconv.ParseInt(starIDStr, 10, 64)

    assetType, title := categoryTypeToAssetType(req.Type)

    items, err := s.repo.GetHotAssetsByAvg(starID, assetType, 8)
    if err != nil {
        return nil, err
    }

    pbItems := s.convertToPbItems(items)

    return &pb.GetHotInspirationFlowResponse{
        Data: &pb.HotCategoryItem{
            Type:  req.Type,
            Title: title,
            Items: pbItems,
        },
    }, nil
}
  • Step 5: 实现 GetHotInspirationFlowMore查看更多分页
// GetHotInspirationFlowMore 查看更多分页
func (s *galleryService) GetHotInspirationFlowMore(ctx context.Context, req *pb.GetHotInspirationFlowMoreRequest) (*pb.GetHotInspirationFlowMoreResponse, error) {
    // 从 context 中获取 starID
    starIDStr := ctx.Value(constant.AttachmentKey).(map[string]interface{})["star_id"].(string)
    starID, _ := strconv.ParseInt(starIDStr, 10, 64)

    // 默认值处理
    limit := req.Limit
    if limit <= 0 {
        limit = 20
    }
    if limit > 50 {
        limit = 50
    }

    assetType, _ := categoryTypeToAssetType(req.Type)

    // 解析 cursorBase64 编码的 JSON包含 like_count 和 asset_id
    var cursorLikeCount int64 = 0
    var cursorAssetID int64 = 0
    if req.Cursor != "" {
        decoded, err := base64.StdEncoding.DecodeString(req.Cursor)
        if err == nil {
            var cursorData map[string]interface{}
            if json.Unmarshal(decoded, &cursorData) == nil {
                if lc, ok := cursorData["like_count"].(float64); ok {
                    cursorLikeCount = int64(lc)
                }
                if id, ok := cursorData["asset_id"].(float64); ok {
                    cursorAssetID = int64(id)
                }
            }
        }
    }

    // 查询数据(使用正确的 starID
    items, total, err := s.repo.GetHotAssetsByAvgWithCursor(starID, assetType, cursorLikeCount, cursorAssetID, int(limit))
    if err != nil {
        return nil, err
    }

    // 转换为 pb
    pbItems := s.convertToPbItems(items)

    // 生成新 cursor编码 like_count 和 asset_id
    newCursor := ""
    if len(items) > 0 {
        lastItem := items[len(items)-1]
        newCursor = base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"like_count":%d,"asset_id":%d}`, lastItem.LikeCount, lastItem.AssetID)))
    }

    // 判断是否还有更多
    hasMore := int64(len(items)) < total

    return &pb.GetHotInspirationFlowMoreResponse{
        Data: &pb.InspirationFlowData{
            Items:   pbItems,
            Cursor:  newCursor,
            HasMore: hasMore,
        },
    }, nil
}

// convertToPbItems 转换为 pb 列表
func (s *galleryService) convertToPbItems(items []*InspirationFlowItem) []*pb.InspirationFlowItem {
    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,
            OwnerAvatar:   item.OwnerAvatar,
            Span:          item.Span,
            MaterialType:  item.MaterialType,
        })
    }
    return pbItems
}
  • Step 6: 提交
git add services/galleryService/service/gallery_service.go
git commit -m "feat: add hot inspiration flow service methods"

Task 5: 新增 Gateway Controller 方法

Files:

  • Modify: backend/gateway/controller/gallery_controller.go

  • Step 1: 添加新的 Controller 方法

// GetHotInspirationFlowBatch 批量获取热门分类
// @Summary 批量获取热门分类
// @Description 页面加载时一次性获取所有热门分类(无参数)
// @Tags galleries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response{data=[]dto.HotCategoryItemDTO}
// @Router /api/v1/inspiration-flow/hot/batch [get]
func (ctrl *GalleryController) GetHotInspirationFlowBatch(c *gin.Context) {
    userID, exists := c.Get("user_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "用户未认证")
        return
    }
    starID, exists := c.Get("star_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "明星身份未设置")
        return
    }

    ctx := context.WithValue(context.Background(), constant.AttachmentKey, map[string]interface{}{
        "user_id": strconv.FormatInt(userID.(int64), 10),
        "star_id": strconv.FormatInt(starID.(int64), 10),
    })

    resp, err := ctrl.galleryService.GetHotInspirationFlowBatch(ctx, &pb.GetEmpty{})
    if err != nil {
        logger.Logger.Error("GetHotInspirationFlowBatch failed", zap.Error(err))
        response.Error(c, http.StatusInternalServerError, "获取热门分类失败")
        return
    }

    response.Success(c, resp.Categories)
}

// GetHotInspirationFlow 单个分类刷新
// @Summary 单个分类刷新
// @Description 点击刷新按钮时调用
// @Tags galleries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type query string true "分类类型hot_recommend/hot_star_card/hot_badge/hot_poster"
// @Success 200 {object} response.Response{data=dto.HotCategoryItemDTO}
// @Router /api/v1/inspiration-flow/hot [get]
func (ctrl *GalleryController) GetHotInspirationFlow(c *gin.Context) {
    userID, exists := c.Get("user_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "用户未认证")
        return
    }
    starID, exists := c.Get("star_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "明星身份未设置")
        return
    }

    categoryType := c.Query("type")
    if categoryType == "" {
        response.Error(c, http.StatusBadRequest, "缺少 type 参数")
        return
    }

    ctx := context.WithValue(context.Background(), constant.AttachmentKey, map[string]interface{}{
        "user_id": strconv.FormatInt(userID.(int64), 10),
        "star_id": strconv.FormatInt(starID.(int64), 10),
    })

    resp, err := ctrl.galleryService.GetHotInspirationFlow(ctx, &pb.GetHotTypeRequest{Type: categoryType})
    if err != nil {
        logger.Logger.Error("GetHotInspirationFlow failed", zap.Error(err))
        response.Error(c, http.StatusInternalServerError, "刷新热门分类失败")
        return
    }

    response.Success(c, resp.Data)
}

// GetHotInspirationFlowMore 查看更多分页
// @Summary 查看更多分页
// @Description 热门分类查看更多
// @Tags galleries
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type query string true "分类类型hot_recommend/hot_star_card/hot_badge/hot_poster"
// @Param cursor query string false "翻页游标Base64 编码的 JSON"
// @Param limit query int false "每页数量" default(20)
// @Success 200 {object} response.Response{data=dto.InspirationFlowDataDTO}
// @Router /api/v1/inspiration-flow/hot/more [get]
func (ctrl *GalleryController) GetHotInspirationFlowMore(c *gin.Context) {
    userID, exists := c.Get("user_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "用户未认证")
        return
    }
    starID, exists := c.Get("star_id")
    if !exists {
        response.Error(c, http.StatusUnauthorized, "明星身份未设置")
        return
    }

    categoryType := c.Query("type")
    if categoryType == "" {
        response.Error(c, http.StatusBadRequest, "缺少 type 参数")
        return
    }

    cursor := c.Query("cursor")
    limitStr := c.DefaultQuery("limit", "20")
    limit, _ := strconv.Atoi(limitStr)

    ctx := context.WithValue(context.Background(), constant.AttachmentKey, map[string]interface{}{
        "user_id": strconv.FormatInt(userID.(int64), 10),
        "star_id": strconv.FormatInt(starID.(int64), 10),
    })

    resp, err := ctrl.galleryService.GetHotInspirationFlowMore(ctx, &pb.GetHotInspirationFlowMoreRequest{
        Type:   categoryType,
        Cursor: cursor,
        Limit:  int32(limit),
    })
    if err != nil {
        logger.Logger.Error("GetHotInspirationFlowMore failed", zap.Error(err))
        response.Error(c, http.StatusInternalServerError, "获取更多热门分类失败")
        return
    }

    response.Success(c, resp.Data)
}
  • Step 2: 提交
git add gateway/controller/gallery_controller.go
git commit -m "feat: add hot inspiration flow controller methods"

Task 6: 更新 Router 注册路由

Files:

  • Modify: backend/gateway/router/router.go

  • Step 1: 在 inspiration-flow 路由组添加新路由

找到现有的 inspiration-flow 路由组,修改为:

// 灵感瀑布相关路由(需要认证)
inspirationFlow := v1.Group("/inspiration-flow")
inspirationFlow.Use(middleware.AuthMiddleware())
{
    inspirationFlow.GET("", galleryCtrl.GetInspirationFlow)                              // 灵感瀑布流
    inspirationFlow.GET("/hot/batch", galleryCtrl.GetHotInspirationFlowBatch)           // 批量获取热门分类(必须在 /hot 之前)
    inspirationFlow.GET("/hot", galleryCtrl.GetHotInspirationFlow)                     // 单个分类刷新
    inspirationFlow.GET("/hot/more", galleryCtrl.GetHotInspirationFlowMore)             // 查看更多分页
}

注意/hot/batch 路由必须在 /hot 之前注册,否则 /hot/batch 会被 /hot 匹配到。

  • Step 2: 提交
git add gateway/router/router.go
git commit -m "feat: register hot inspiration flow routes"

Task 7: 更新 DTOData Transfer Object

Files:

  • Modify: backend/gateway/dto/gallery_dto.go

  • Step 1: 添加 DTO 结构体

// HotCategoryItemDTO 热门分类条目
type HotCategoryItemDTO struct {
    Type  string                   `json:"type"`
    Title string                  `json:"title"`
    Items []*InspirationFlowItemDTO `json:"items"`
}

// GetHotInspirationFlowBatchResponseDTO 批量获取热门分类响应
type GetHotInspirationFlowBatchResponseDTO struct {
    Categories []*HotCategoryItemDTO `json:"categories"`
}
  • Step 2: 添加转换函数gallery_converter.go
// ConvertHotCategoryItem 转换热门分类条目
func ConvertHotCategoryItem(pbItem *pb.HotCategoryItem) *HotCategoryItemDTO {
    if pbItem == nil {
        return nil
    }
    items := make([]*InspirationFlowItemDTO, 0, len(pbItem.Items))
    for _, item := range pbItem.Items {
        items = append(items, ConvertInspirationFlowItem(item))
    }
    return &HotCategoryItemDTO{
        Type:  pbItem.Type,
        Title: pbItem.Title,
        Items: items,
    }
}

// ConvertHotCategoryItems 批量转换
func ConvertHotCategoryItems(pbItems []*pb.HotCategoryItem) []*HotCategoryItemDTO {
    if pbItems == nil {
        return nil
    }
    result := make([]*HotCategoryItemDTO, 0, len(pbItems))
    for _, item := range pbItems {
        if dto := ConvertHotCategoryItem(item); dto != nil {
            result = append(result, dto)
        }
    }
    return result
}
  • Step 3: 提交
git add gateway/dto/gallery_dto.go gateway/dto/gallery_converter.go
git commit -m "feat: add DTO for hot inspiration flow"

Task 8: 调整现有 GetInspirationFlow 逻辑(灵感瀑布流)

Files:

  • Modify: backend/services/galleryService/service/gallery_service.go

  • Modify: backend/services/galleryService/repository/gallery_repository.go

  • Step 1: 在 Repository 添加分段查询方法 GetInspirationFlowByAvgSegments

// GetInspirationFlowByAvgSegments 分段获取灵感瀑布作品
// 第一段:点赞 > 平均值,基于时间窗口伪随机排列,取前 limit 条
// 第二段:如果不够,从 >= 平均值的作品中(排除第一段已选的)补充
func (r *galleryRepository) GetInspirationFlowByAvgSegments(starID int64, materialType string, excludeIDs []int64, limit int) ([]*InspirationFlowItem, error) {
    var items []*InspirationFlowItem
    now := time.Now().UnixMilli()

    // 构建基础查询
    baseQuery := r.db.Model(&models.Exhibition{}).
        Where("exhibitions.occupier_star_id = ? AND exhibitions.expire_at > ? AND exhibitions.deleted_at IS NULL", starID, now).
        Joins("JOIN assets a ON a.id = exhibitions.asset_id").
        Where("a.status = 1 AND a.is_active = true")

    if materialType != "" && materialType != "all" {
        baseQuery = baseQuery.Where("a.material_type LIKE ?", "%"+materialType+"%")
    }

    // 排除已展示
    if len(excludeIDs) > 0 {
        baseQuery = baseQuery.Where("exhibitions.id NOT IN ?", excludeIDs)
    }

    // 计算平均值
    var avgLikes float64
    err := baseQuery.Select("COALESCE(AVG(a.like_count), 0)").Scan(&avgLikes).Error
    if err != nil {
        return nil, err
    }

    // 第一段:点赞 > 平均值,基于时间窗口伪随机
    windowSeed := now / 30000 % 10
    err = baseQuery.
        Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count,
                COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, fp.avatar_url as owner_avatar,
                a.material_type, a.created_at`).
        Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id").
        Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id").
        Where("a.like_count > ?", avgLikes).
        Where("exhibitions.id % 10 = ?", windowSeed).
        Order("exhibitions.id").
        Limit(limit).
        Scan(&items).Error

    if err != nil {
        return nil, err
    }

    // 如果第一段不够,补充第二段(点赞数 >= 平均值,排除已选)
    if len(items) < limit {
        remaining := limit - len(items)
        var extraItems []*InspirationFlowItem
        err = baseQuery.
            Where("a.like_count >= ?", avgLikes).
            Where("exhibitions.id % 10 != ?", windowSeed).
            Select(`exhibitions.id as exhibition_id, exhibitions.asset_id, a.name, a.cover_url, a.like_count,
                    COALESCE(alr.current_level, 'N') as level, fp.nickname as owner_nickname, fp.avatar_url as owner_avatar,
                    a.material_type, a.created_at`).
            Joins("LEFT JOIN asset_level_records alr ON alr.asset_id = a.id").
            Joins("JOIN fan_profiles fp ON exhibitions.occupier_uid = fp.user_id AND exhibitions.occupier_star_id = fp.star_id").
            Order("exhibitions.id").
            Limit(remaining).
            Scan(&extraItems).Error

        if err != nil {
            return nil, err
        }
        items = append(items, extraItems...)
    }

    // 填充 Span
    for _, item := range items {
        item.Span = calcSpanByLevel(item.Level)
    }

    return items, nil
}
  • Step 2: 修改 Service 层的 GetInspirationFlow 方法

GetInspirationFlow 方法中的 GetRandomExhibitions 调用替换为 GetInspirationFlowByAvgSegments

  • Step 3: 提交
git add services/galleryService/service/gallery_service.go services/galleryService/repository/gallery_repository.go
git commit -m "refactor: adjust inspiration flow to use avg-based segment query"

自检清单

  1. Spec 覆盖检查:

    • 批量接口 /hot/batch - Task 1, 2, 3, 4, 5, 6
    • 单个刷新 /hot - Task 1, 4, 5, 6
    • 查看更多 /hot/more - Task 1, 3, 4, 5, 6
    • 灵感瀑布调整 - Task 8
    • 并行查询 - Task 4
    • 缓存优化(点赞均值缓存 5min批量结果缓存 30s单个刷新不缓存 - Task 2, 4
    • 伪随机算法 - Task 3
    • Cursor 编码 (like_count, asset_id) - Task 3, 4
  2. Placeholder 扫描: 无 "TBD"、"TODO" 占位符

  3. 类型一致性: Proto 消息与 Go 结构体匹配Cursor 编码格式正确


Plan complete and saved to docs/superpowers/plans/2026-05-27-热门推荐模块实现.md.

Two execution options:

1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?