1035 lines
35 KiB
Markdown
1035 lines
35 KiB
Markdown
# 热门推荐模块实现计划
|
||
|
||
> **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 中获取 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(单个刷新,不缓存结果)**
|
||
|
||
```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)
|
||
|
||
// 解析 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: 提交**
|
||
|
||
```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: 更新 DTO(Data 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?** |