diff --git a/docs/api-phase1.md b/docs/api-phase1.md index df701e4..a8f27c1 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -173,6 +173,10 @@ app.register.max-per-device-per-day > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 +> +> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串), +> 点赞、收藏取 `drama` 冗余计数字段,评论数由 `content_comment` 按 `drama_id` 批量统计, +> 排除逻辑删除与 `status = 'deleted'` 的记录。首页、发现、分类、收藏、搜索等列表卡片结构一致。 #### 短剧详情响应(`GET /app/dramas/{id}`) diff --git a/docs/plans/INBOX.md b/docs/plans/INBOX.md index 0fab7bd..5f2e922 100644 --- a/docs/plans/INBOX.md +++ b/docs/plans/INBOX.md @@ -1,59 +1,58 @@ # INBOX -## [已处理] K83-A 右侧互动栏改用参考图同款图标资源 +## [已处理] K84-A 列表卡片与后台补充收藏 / 点赞 / 评论数 ### 背景 -K82-A 用 Material 图标替换后,形状与参考图仍不一致(星形/气泡/心形/分享箭头细节不同), -且 Material 字形自带内边距,视觉上比参考图小。 +右侧互动栏要显示收藏、评论、喜欢的数量,但首页 feed 等列表卡片拿不到这三个数。 -已从产品提供的参考图中提取出四个图标(白色 + 透明通道,保留原始形状与相对大小), -放在仓库 `assets/icon/rail/`: +已确认: -- `rail_save.png`(收藏,实心星) -- `rail_comment.png`(评论,带三个点的气泡) -- `rail_like.png`(喜欢,实心心形) -- `rail_share.png`(分享,弯向右的箭头) +- 数据库 `drama` 表有 `favorite_count`、`like_count`(无 comment_count 列,评论数按 + `content_comment` 统计,见 `AppDramaDetailVO.commentCount`) +- `AppDramaDetailVO` 已返回 `likeCount / favoriteCount / commentCount` +- **`AppDramaCardVO`(列表卡片)只有 `viewCount`,缺这三个字段** → 客户端列表阶段拿不到 +- 后台 `DramaRespVO` 只有 `views`,也缺这三个数 +- 客户端 `Drama.fromJson` 已经解析 `likeCount / favoriteCount / commentCount`,**客户端无需改** -### 改动 +### 任务(FruitServer 仓库) -1. `pubspec.yaml` - - `flutter.assets` 增加 `assets/icon/rail/`(目录) -2. `lib/pages/player/widgets/interaction_rail.dart` - - `InteractionRailItem` 的 `IconData icon / activeIcon` 改为 `String asset` - (`selected` 只决定颜色,不再切换图标) - - 渲染改为: - ```dart - Image.asset( - item.asset, - width: _iconSize, - height: _iconSize, - fit: BoxFit.contain, - color: item.selected ? AppColors.blue : Colors.white, - colorBlendMode: BlendMode.srcIn, - ) - ``` - - `_iconSize`:`30` → `32`;`_buttonSize` 保持 `44`;`_itemGap`:`22` → `24` - - 删除 K82-A 引入的 `mirrorIcon`(新资源本身已朝右) -3. `lib/pages/player/widgets/home_feed_overlay.dart` 与 `player_overlay.dart` - - 四项分别传:`assets/icon/rail/rail_save.png`、`rail_comment.png`、`rail_like.png`、`rail_share.png` -4. 测试 - - 把 `find.byIcon(Icons.star_rounded / favorite_rounded / reply_rounded / ios_share_rounded …)` 类断言 - 改为查找对应 `Image.asset`(或用 Key 定位后断言 `Image.image` 的 asset 名) - - Key(`rail-save`、`rail-comments`、`rail-like`、`rail-share`、`player-rail-*`、计数 Key)保持不变 +1. `fruitshorts-app-api/.../vo/AppDramaCardVO.java` + - 增加 `Long likeCount`、`Long favoriteCount`、`Long commentCount` +2. `fruitshorts-app-api/.../service/AppCatalogService.java` + - `cards()` 里一次性批量取评论数:`Map commentCounts = commentService.countByDramas(ids)` + - `toCard()` 填充: + - `likeCount` ← `drama.getLikeCount()` + - `favoriteCount` ← `drama.getFavoriteCount()` + - `commentCount` ← `commentCounts.getOrDefault(drama.getId(), 0L)` + - 不要逐条 `countByDrama`(避免 N+1) +3. `fruitshorts-module-interaction/.../service/CommentService.java` + - 新增 `Map countByDramas(Collection dramaIds)`: + `deleted = 0` 且 `drama_id in (...)`,按 `drama_id` 分组计数;空集合返回空 Map + - 现有 `countByDrama(Long)` 可复用该方法的单元素结果或保持不动 +4. 后台管理端(注意模块依赖方向:`module-interaction` 依赖 `module-content`,不能反向依赖) + - `fruitshorts-module-content/.../vo/DramaRespVO.java` 增加 `likeCount / favoriteCount / commentCount` + - `DramaService`(module-content)只填充 `likeCount / favoriteCount`(取实体字段), + `commentCount` 先留空 + - `fruitshorts-admin-api` 的 Drama 接口(`DramaController` 或 admin 侧小服务)在分页/详情返回前, + 用 `CommentService.countByDramas(ids)` 批量回填 `commentCount` + (分页一次查询;详情单条) +5. 测试 + - `AppCatalogControllerContractTest`:列表卡片接口增加 `likeCount / favoriteCount / commentCount` 断言 + - 后台分页/详情如有测试,补充三个字段断言 + - `CommentService.countByDramas` 补单测(空集合 / 多剧集分组) ### 验收 -- 门禁:`dart analyze lib test` 零告警、`flutter test` 全过、`flutter build apk --debug` 成功。 -- 真机自查:首页 feed 与全屏播放页右侧四个图标与参考图形状一致,大小一致,无深色圆底。 +- 门禁:`mvn test` 全过。 +- 接口自测:`GET /app/home/feed`(或现有首页聚合接口)返回的每个卡片都带三个数字段。 ### Git -- 从 `develop` 拉 `feat/rail-icon-assets`。 -- 提交:`feat(player): 互动栏改用参考图同款图标资源`,作者 `Zhou Weidong <502333560@qq.com>`。 -- `--no-ff` 合回 `develop` 并 push;`FruitServer/docs/plans/PROGRESS.md` 追加 K83-A 留痕。 +- 从 `develop` 拉 `feat/drama-card-counts`。 +- 提交:`feat(content): 列表卡片与后台返回收藏点赞评论数`,作者 `Zhou Weidong <502333560@qq.com>`。 +- `--no-ff` 合回 `develop` 并 push;`FruitServer/docs/plans/PROGRESS.md` 追加 K84-A 留痕。 ### 约束 -- 全程中文;禁止 `git add -A`;图标资源已放到 `assets/icon/rail/`,不要重新绘制或改动形状; - 不改互动逻辑与 Key;完成后停住等待验收。 +- 全程中文;禁止 `git add -A`;不改客户端代码、不改 specs 仓库;完成后停住等待验收。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index f23cd48..d1e0326 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -22884,3 +22884,31 @@ Running Gradle task 'assembleDebug'... ``` 解读:静态检查零告警,490 条测试全部通过,debug APK 构建成功。真机验收由用户执行。 + +## 2026-10-21 FruitServer:K84-A 列表卡片与后台补充互动计数 + +- 前置状态:K83-A 文档留痕完成,FruitServer develop 提交 `f903369`。 +- 分支:`feat/drama-card-counts` +- `AppDramaCardVO` 新增 `likeCount` / `favoriteCount` / `commentCount`,首页、发现、分类、收藏、搜索等复用卡片的接口统一返回三个字段。 +- `AppCatalogService.cards()` 对每批卡片只调用一次 `CommentService.countByDramas(ids)`,点赞/收藏取 `Drama` 实体冗余字段,评论缺失时回填 0,避免逐条查询。 +- `CommentService` 新增按 `drama_id` 分组批量统计,过滤逻辑删除与 `status = 'deleted'`,空集合不查询数据库。 +- 后台 `DramaRespVO` 新增三个字段;`module-content` 只填点赞/收藏,`admin-api` 的分页和详情接口从 `module-interaction` 批量回填评论数,模块依赖方向保持不变。 +- `docs/api-phase1.md` 已同步卡片互动计数契约;未修改 Flutter 客户端、specs 仓库或其它业务逻辑。 +- 测试覆盖列表卡片三个字段契约、单次批量查询、空集合、多剧分组统计,以及后台分页/详情三个字段回填。 + +门禁说明:本机默认 JDK 27 不兼容既有 Mockito inline 测试,首次尝试在既有 commerce 模块失败;按项目要求固定 JDK 21 后完整重跑通过。 + +门禁原始输出: + +```text +$ JAVA_HOME=/opt/homebrew/Cellar/openjdk@21/21.0.12.1/libexec/openjdk.jdk/Contents/Home mvn test +[INFO] Reactor Summary for fruitshorts 1.0.0-SNAPSHOT: +[INFO] fruitshorts-module-interaction ..................... SUCCESS +[INFO] fruitshorts-admin-api ............................... SUCCESS +[INFO] fruitshorts-app-api ................................. SUCCESS +[INFO] fruitshorts-transcode-worker ........................ SUCCESS +[INFO] BUILD SUCCESS +[INFO] Total time: 14.294 s +``` + +解读:JDK 21 下完整 Maven 测试通过,App/Admin 互动计数契约与既有模块测试均保持绿色。接口联调验收由用户执行。 diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java index 6ad21ce..1b66456 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java @@ -10,8 +10,9 @@ import com.fruitshorts.content.vo.DramaPageReqVO; import com.fruitshorts.content.vo.DramaRespVO; import com.fruitshorts.content.vo.DramaSaveReqVO; import com.fruitshorts.content.vo.DramaStatusReqVO; -import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.interaction.service.CommentService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; @Tag(name = "后台-短剧管理") @RestController @@ -35,19 +37,24 @@ import java.util.List; public class DramaController { private final DramaService dramaService; + private final CommentService commentService; @Operation(summary = "短剧分页查询") @GetMapping("/page") @PreAuthorize("@ss.hasPermission('drama:list')") public Result> page(DramaPageReqVO req) { - return Result.ok(dramaService.page(req)); + PageResult page = dramaService.page(req); + fillCommentCounts(page.getList()); + return Result.ok(page); } @Operation(summary = "短剧详情") @GetMapping("/{id}") @PreAuthorize("@ss.hasPermission('drama:list')") public Result get(@PathVariable Long id) { - return Result.ok(dramaService.get(id)); + DramaRespVO drama = dramaService.get(id); + fillCommentCounts(List.of(drama)); + return Result.ok(drama); } @Operation(summary = "新建短剧") @@ -133,4 +140,13 @@ public class DramaController { dramaService.changeStatus(id, action, req == null ? null : req.getReason(), SecurityUtils.getUserId(), SecurityUtils.getUsername()); } + + private void fillCommentCounts(List dramas) { + if (dramas == null || dramas.isEmpty()) { + return; + } + List ids = dramas.stream().map(DramaRespVO::getId).toList(); + Map counts = commentService.countByDramas(ids); + dramas.forEach(drama -> drama.setCommentCount(counts.getOrDefault(drama.getId(), 0L))); + } } diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java new file mode 100644 index 0000000..79be4de --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java @@ -0,0 +1,83 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.vo.DramaPageReqVO; +import com.fruitshorts.content.vo.DramaRespVO; +import com.fruitshorts.interaction.service.CommentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 后台短剧列表与详情的互动数量回填契约。 */ +class DramaControllerContractTest { + + private DramaService dramaService; + private CommentService commentService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + dramaService = mock(DramaService.class); + commentService = mock(CommentService.class); + mockMvc = ControllerContractSupport.mockMvcOf( + new DramaController(dramaService, commentService)); + } + + @Test + void should_fill_comment_counts_in_batch_when_page_is_requested() throws Exception { + DramaRespVO first = drama(7L, 3200L, 1800L); + DramaRespVO second = drama(8L, 120L, 80L); + given(dramaService.page(any(DramaPageReqVO.class))) + .willReturn(new PageResult<>(2L, List.of(first, second))); + given(commentService.countByDramas(List.of(7L, 8L))) + .willReturn(Map.of(7L, 11L)); + + mockMvc.perform(get("/admin/dramas/page")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].likeCount").value("3200")) + .andExpect(jsonPath("$.data.list[0].favoriteCount").value("1800")) + .andExpect(jsonPath("$.data.list[0].commentCount").value("11")) + .andExpect(jsonPath("$.data.list[1].likeCount").value("120")) + .andExpect(jsonPath("$.data.list[1].favoriteCount").value("80")) + .andExpect(jsonPath("$.data.list[1].commentCount").value("0")); + + verify(commentService).countByDramas(List.of(7L, 8L)); + } + + @Test + void should_fill_comment_count_when_detail_is_requested() throws Exception { + given(dramaService.get(7L)).willReturn(drama(7L, 3200L, 1800L)); + given(commentService.countByDramas(List.of(7L))).willReturn(Map.of(7L, 11L)); + + mockMvc.perform(get("/admin/dramas/7")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.likeCount").value("3200")) + .andExpect(jsonPath("$.data.favoriteCount").value("1800")) + .andExpect(jsonPath("$.data.commentCount").value("11")); + } + + private DramaRespVO drama(Long id, Long likeCount, Long favoriteCount) { + DramaRespVO drama = new DramaRespVO(); + drama.setId(id); + drama.setTitle("短剧" + id); + drama.setLikeCount(likeCount); + drama.setFavoriteCount(favoriteCount); + return drama; + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index 21061e7..291ebb4 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -144,15 +144,17 @@ public class AppCatalogService { /** 整屏卡片共用一次剧集查询,取每部剧的首个可播免费集作为预览。 */ private List cards(List dramas, Map categoryNames) { - Map previews = episodeService.firstPlayableByDramas( - dramas.stream().map(Drama::getId).toList()); + List ids = dramas.stream().map(Drama::getId).toList(); + Map previews = episodeService.firstPlayableByDramas(ids); + Map commentCounts = commentService.countByDramas(ids); return dramas.stream() - .map(drama -> toCard(drama, categoryNames, previews.get(drama.getId()))) + .map(drama -> toCard( + drama, categoryNames, previews.get(drama.getId()), commentCounts)) .toList(); } private AppDramaCardVO toCard(Drama drama, Map categoryNames, - DramaEpisode preview) { + DramaEpisode preview, Map commentCounts) { AppDramaCardVO card = new AppDramaCardVO(); card.setId(drama.getId()); card.setTitle(drama.getTitle()); @@ -163,6 +165,9 @@ public class AppCatalogService { card.setTotalEpisodes(drama.getTotalEpisodes()); card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1); card.setViewCount(drama.getViewCount()); + card.setLikeCount(drama.getLikeCount()); + card.setFavoriteCount(drama.getFavoriteCount()); + card.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L)); card.setLanguage(drama.getLanguage()); card.setPreview(preview == null ? null : toEpisode(preview)); return card; diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java index b71d800..4310add 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java @@ -18,6 +18,12 @@ public class AppDramaCardVO { private Integer totalEpisodes; private Boolean isHot; private Long viewCount; + /** 短剧累计点赞数:drama.like_count */ + private Long likeCount; + /** 短剧累计收藏数:drama.favorite_count */ + private Long favoriteCount; + /** 该剧未删除评论数:content_comment 按 drama_id 统计 */ + private Long commentCount; /** 语种,供客户端按语言筛选(如 zh-CN / id)。 */ @Schema(description = "短剧语种,如 zh-CN / id") private String language; diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java index 4a2a034..c7d6c54 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java @@ -5,8 +5,10 @@ import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.app.vo.AppBannerVO; import com.fruitshorts.app.vo.AppCategoryVO; +import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppFeedVO; +import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.playback.service.PlaybackService; @@ -50,7 +52,15 @@ class AppCatalogControllerContractTest { banner.setImageUrl("/files/banner/9.png"); banner.setLinkType("DRAMA"); banner.setLinkValue("12"); - AppFeedVO feed = new AppFeedVO(List.of(banner), List.of()); + AppDramaCardVO card = new AppDramaCardVO(); + card.setId(12L); + card.setTitle("命运的相遇"); + card.setViewCount(12400L); + card.setLikeCount(3200L); + card.setFavoriteCount(1800L); + card.setCommentCount(96L); + AppSectionVO section = new AppSectionVO("hot", "热门推荐", List.of(card)); + AppFeedVO feed = new AppFeedVO(List.of(banner), List.of(section)); given(catalogService.homeFeed()).willReturn(feed); catalogMvc.perform(get("/app/home/feed")) @@ -58,7 +68,11 @@ class AppCatalogControllerContractTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.banners[0].id").value(9)) .andExpect(jsonPath("$.data.banners[0].imageUrl").value("/files/banner/9.png")) - .andExpect(jsonPath("$.data.banners[0].linkType").value("DRAMA")); + .andExpect(jsonPath("$.data.banners[0].linkType").value("DRAMA")) + .andExpect(jsonPath("$.data.sections[0].dramas[0].viewCount").value("12400")) + .andExpect(jsonPath("$.data.sections[0].dramas[0].likeCount").value("3200")) + .andExpect(jsonPath("$.data.sections[0].dramas[0].favoriteCount").value("1800")) + .andExpect(jsonPath("$.data.sections[0].dramas[0].commentCount").value("96")); } @Test diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java index 3cb3ab3..cd726da 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -1,6 +1,7 @@ // Author: fruit.com.cn package com.fruitshorts.app.service; +import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.service.BannerService; @@ -13,10 +14,13 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; /** @@ -63,4 +67,36 @@ class AppCatalogServiceTest { assertThat(detail.getCommentCount()).isEqualTo(96L); verify(commentService).countByDrama(42L); } + + @Test + void should_fill_card_counts_with_one_batch_comment_query() { + Drama first = new Drama(); + first.setId(1L); + first.setTitle("第一部"); + first.setViewCount(1200L); + first.setLikeCount(320L); + first.setFavoriteCount(180L); + Drama second = new Drama(); + second.setId(2L); + second.setTitle("第二部"); + second.setViewCount(800L); + second.setLikeCount(120L); + second.setFavoriteCount(80L); + given(episodeService.firstPlayableByDramas(List.of(1L, 2L))).willReturn(Map.of()); + given(dramaCategoryService.listBasic()).willReturn(List.of()); + given(commentService.countByDramas(List.of(1L, 2L))) + .willReturn(Map.of(1L, 9L)); + + List cards = catalogService.toCards(List.of(first, second)); + + assertThat(cards).hasSize(2); + assertThat(cards.get(0).getLikeCount()).isEqualTo(320L); + assertThat(cards.get(0).getFavoriteCount()).isEqualTo(180L); + assertThat(cards.get(0).getCommentCount()).isEqualTo(9L); + assertThat(cards.get(1).getLikeCount()).isEqualTo(120L); + assertThat(cards.get(1).getFavoriteCount()).isEqualTo(80L); + assertThat(cards.get(1).getCommentCount()).isZero(); + verify(commentService).countByDramas(List.of(1L, 2L)); + verify(commentService, never()).countByDrama(any()); + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CommentServiceCountTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CommentServiceCountTest.java index 4de972b..c99717f 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CommentServiceCountTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CommentServiceCountTest.java @@ -22,6 +22,10 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.apache.ibatis.builder.MapperBuilderAssistant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -77,4 +81,32 @@ class CommentServiceCountTest { assertThat(commentService.countByDrama(null)).isZero(); verify(commentMapper, never()).selectCount(any()); } + + @Test + void should_return_empty_map_without_query_when_drama_ids_are_empty() { + assertThat(commentService.countByDramas(List.of())).isEmpty(); + verify(commentMapper, never()).selectMaps(any()); + } + + @Test + void should_group_comment_counts_when_multiple_dramas_are_requested() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + given(commentMapper.selectMaps(captor.capture())).willReturn(List.of( + Map.of("drama_id", 7L, "comment_count", 5L), + Map.of("drama_id", 8L, "comment_count", 2L))); + + Map counts = commentService.countByDramas( + Arrays.asList(7L, 8L, 8L, null)); + + assertThat(counts).containsEntry(7L, 5L).containsEntry(8L, 2L); + Wrapper wrapper = captor.getValue(); + assertThat(wrapper.getSqlSegment()) + .contains("drama_id") + .contains("status") + .contains("deleted") + .contains("GROUP BY"); + AbstractWrapper lambda = (AbstractWrapper) wrapper; + assertThat(lambda.getParamNameValuePairs().values()) + .contains(7L, 8L, "deleted"); + } } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index 9834021..d87443d 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -325,6 +325,8 @@ public class DramaService { vo.setStatus(drama.getStatus()); vo.setStatusText(ContentStatus.textOf(drama.getStatus())); vo.setViews(drama.getViewCount()); + vo.setLikeCount(drama.getLikeCount()); + vo.setFavoriteCount(drama.getFavoriteCount()); vo.setSort(drama.getSort()); vo.setPublishAt(drama.getPublishedAt()); vo.setCreatedAt(drama.getCreatedAt()); diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java index 66f416c..70e0391 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java @@ -32,6 +32,9 @@ public class DramaRespVO { private String status; private String statusText; private Long views; + private Long likeCount; + private Long favoriteCount; + private Long commentCount; private Integer sort; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java index 77e0441..1459df9 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/CommentService.java @@ -3,6 +3,7 @@ package com.fruitshorts.interaction.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -32,10 +33,12 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -48,6 +51,9 @@ import java.util.Set; @RequiredArgsConstructor public class CommentService { + private static final String DRAMA_ID_COLUMN = "drama_id"; + private static final String COMMENT_COUNT_COLUMN = "comment_count"; + public static final String STATUS_PENDING = "pending"; public static final String STATUS_PUBLISHED = "published"; public static final String STATUS_REJECTED = "rejected"; @@ -185,6 +191,46 @@ public class CommentService { return count == null ? 0L : count; } + /** + * 批量统计多部剧的未删除评论数,按 drama_id 分组,避免逐剧查询。 + */ + public Map countByDramas(Collection dramaIds) { + if (dramaIds == null || dramaIds.isEmpty()) { + return Map.of(); + } + List ids = dramaIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + QueryWrapper wrapper = Wrappers.query(); + wrapper.select(DRAMA_ID_COLUMN, "COUNT(*) AS " + COMMENT_COUNT_COLUMN) + .in(DRAMA_ID_COLUMN, ids) + .eq("deleted", 0) + .ne("status", STATUS_DELETED) + .groupBy(DRAMA_ID_COLUMN); + List> rows = commentMapper.selectMaps(wrapper); + if (rows == null || rows.isEmpty()) { + return Map.of(); + } + Map counts = new HashMap<>(); + for (Map row : rows) { + Long dramaId = asLong(row.get(DRAMA_ID_COLUMN)); + if (dramaId != null) { + counts.put(dramaId, asLong(row.get(COMMENT_COUNT_COLUMN), 0L)); + } + } + return counts; + } + + private Long asLong(Object value) { + return value instanceof Number number ? number.longValue() : null; + } + + private long asLong(Object value, long defaultValue) { + Long number = asLong(value); + return number == null ? defaultValue : number; + } + // ---------------- 后台 ---------------- public PageResult adminPage(CommentQueryReqVO req) {