35 KiB
热门推荐模块实现计划
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 |
新增 GetHotInspirationFlowBatch、GetHotInspirationFlow、GetHotInspirationFlowMore 方法 |
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 |
新增 GetHotInspirationFlowBatch、GetHotInspirationFlow、GetHotInspirationFlowMore 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 中获取 starID(Dubbo 协议通过 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)
// 解析 cursor(Base64 编码的 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: 更新 DTO(Data 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"
自检清单
-
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
- 批量接口
-
Placeholder 扫描: 无 "TBD"、"TODO" 占位符
-
类型一致性: 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?