diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 5627823..cd22dd5 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -539,32 +539,85 @@ func (s *content) ListTopics(ctx context.Context, tenantID int64) ([]content_dto return nil, errorx.ErrDatabaseError.WithCause(err) } - var topics []content_dto.Topic + if len(results) == 0 { + return []content_dto.Topic{}, nil + } + + genres := make([]string, 0, len(results)) + for _, r := range results { + if r.Genre == "" { + continue + } + genres = append(genres, r.Genre) + } + + contentIDByGenre := make(map[string]int64, len(genres)) + contentMap := make(map[int64]*models.Content, len(genres)) + if len(genres) > 0 { + // 批量获取每个分类最新内容,避免逐条查询导致 N+1。 + var rows []struct { + Genre string `gorm:"column:genre"` + ContentID int64 `gorm:"column:content_id"` + } + contentQuery := models.ContentQuery.WithContext(ctx).UnderlyingDB(). + Model(&models.Content{}). + Select("distinct on (genre) genre, id as content_id"). + Where("status = ?", consts.ContentStatusPublished). + Where("genre IN ?", genres). + Order("genre, published_at desc, id desc") + if tenantID > 0 { + contentQuery = contentQuery.Where("tenant_id = ?", tenantID) + } + if err := contentQuery.Scan(&rows).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + contentIDs := make([]int64, 0, len(rows)) + seen := make(map[int64]struct{}, len(rows)) + for _, row := range rows { + if row.ContentID == 0 { + continue + } + contentIDByGenre[row.Genre] = row.ContentID + if _, ok := seen[row.ContentID]; ok { + continue + } + seen[row.ContentID] = struct{}{} + contentIDs = append(contentIDs, row.ContentID) + } + + if len(contentIDs) > 0 { + var contents []*models.Content + if err := models.ContentQuery.WithContext(ctx). + UnderlyingDB(). + Preload("ContentAssets"). + Preload("ContentAssets.Asset"). + Where("id IN ?", contentIDs). + Find(&contents).Error; err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + for _, c := range contents { + contentMap[c.ID] = c + } + } + } + + topics := make([]content_dto.Topic, 0, len(results)) for i, r := range results { if r.Genre == "" { continue } - // Fetch latest content in this genre to get a cover - var c models.Content - query := models.ContentQuery.WithContext(ctx). - Where(models.ContentQuery.Genre.Eq(r.Genre), models.ContentQuery.Status.Eq(consts.ContentStatusPublished)) - if tenantID > 0 { - query = query.Where(models.ContentQuery.TenantID.Eq(tenantID)) - } - query. - Order(models.ContentQuery.PublishedAt.Desc()). - UnderlyingDB(). - Preload("ContentAssets"). - Preload("ContentAssets.Asset"). - First(&c) - cover := "" - for _, ca := range c.ContentAssets { - if (ca.Role == consts.ContentAssetRoleCover || ca.Role == consts.ContentAssetRoleMain) && ca.Asset != nil { - cover = Common.GetAssetURL(ca.Asset.ObjectKey) - if ca.Role == consts.ContentAssetRoleCover { - break // Prefer cover + if contentID, ok := contentIDByGenre[r.Genre]; ok { + if c := contentMap[contentID]; c != nil { + for _, ca := range c.ContentAssets { + if (ca.Role == consts.ContentAssetRoleCover || ca.Role == consts.ContentAssetRoleMain) && ca.Asset != nil { + cover = Common.GetAssetURL(ca.Asset.ObjectKey) + if ca.Role == consts.ContentAssetRoleCover { + break // Prefer cover + } + } } } } diff --git a/docs/todo_list.md b/docs/todo_list.md index 2acaba1..2067fbe 100644 --- a/docs/todo_list.md +++ b/docs/todo_list.md @@ -198,6 +198,7 @@ - 审计操作显式传入操作者信息(服务层不再依赖 ctx 读取)。 - 运营统计报表(overview + CSV 导出基础版)。 - 超管后台治理能力(健康度/异常监控/内容审核)。 +- 性能优化(避免 N+1:topics 聚合批量查询)。 ## 里程碑建议 - M1:完成 P0