# 热门推荐模块实现计划 > **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?**