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

1035 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 热门推荐模块实现计划
> **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` 末尾添加:
```protobuf
// 空请求(用于批量接口)
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` 中添加:
```protobuf
// 批量获取热门分类
rpc GetHotInspirationFlowBatch(GetEmpty) returns (GetHotInspirationFlowBatchResponse);
// 单个分类刷新
rpc GetHotInspirationFlow(GetHotTypeRequest) returns (GetHotInspirationFlowResponse);
// 查看更多分页
rpc GetHotInspirationFlowMore(GetHotInspirationFlowMoreRequest) returns (GetHotInspirationFlowMoreResponse);
```
- [ ] **Step 3: 运行 Proto 生成**
```bash
cd /Users/liulujian/Documents/code/TopFansByGithub/backend
./proto/gen.sh
```
---
## Task 2: 新增 Redis 缓存辅助函数
**Files:**
- Modify: `backend/pkg/database/redis.go`
- [ ] **Step 1: 添加热门推荐缓存常量和结构体**
```go
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: 添加缓存操作函数**
```go
// 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: 提交**
```bash
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 接口添加方法声明**
```go
// ========== 热门推荐相关 ==========
// 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基于时间窗口伪随机**
```go
// 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查看更多分页用**
```go
// 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: 提交**
```bash
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 接口添加方法声明**
```go
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 的映射函数**
```go
// 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并行查询 + 缓存点赞均值)**
```go
// 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单个刷新不缓存结果**
```go
// 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查看更多分页**
```go
// 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: 提交**
```bash
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 方法**
```go
// 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: 提交**
```bash
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 路由组,修改为:
```go
// 灵感瀑布相关路由(需要认证)
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: 提交**
```bash
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 结构体**
```go
// 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**
```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: 提交**
```bash
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**
```go
// 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: 提交**
```bash
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?**