feat(content): 列表卡片与后台返回收藏点赞评论数
This commit is contained in:
1 parent
f90336963b
commit
256821b824
13 files changed
+324
-50
No files matched your search
@@ -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}`)
|
||||
|
||||
|
||||
+40
-41
@@ -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<Long, Long> 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<Long, Long> countByDramas(Collection<Long> 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 仓库;完成后停住等待验收。
|
||||
@@ -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 互动计数契约与既有模块测试均保持绿色。接口联调验收由用户执行。
|
||||
+19
-3
@@ -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<PageResult<DramaRespVO>> page(DramaPageReqVO req) {
|
||||
return Result.ok(dramaService.page(req));
|
||||
PageResult<DramaRespVO> page = dramaService.page(req);
|
||||
fillCommentCounts(page.getList());
|
||||
return Result.ok(page);
|
||||
}
|
||||
|
||||
@Operation(summary = "短剧详情")
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('drama:list')")
|
||||
public Result<DramaRespVO> 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<DramaRespVO> dramas) {
|
||||
if (dramas == null || dramas.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> ids = dramas.stream().map(DramaRespVO::getId).toList();
|
||||
Map<Long, Long> counts = commentService.countByDramas(ids);
|
||||
dramas.forEach(drama -> drama.setCommentCount(counts.getOrDefault(drama.getId(), 0L)));
|
||||
}
|
||||
}
|
||||
+83
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -144,15 +144,17 @@ public class AppCatalogService {
|
||||
|
||||
/** 整屏卡片共用一次剧集查询,取每部剧的首个可播免费集作为预览。 */
|
||||
private List<AppDramaCardVO> cards(List<Drama> dramas, Map<Long, String> categoryNames) {
|
||||
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(
|
||||
dramas.stream().map(Drama::getId).toList());
|
||||
List<Long> ids = dramas.stream().map(Drama::getId).toList();
|
||||
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
|
||||
Map<Long, Long> 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<Long, String> categoryNames,
|
||||
DramaEpisode preview) {
|
||||
DramaEpisode preview, Map<Long, Long> 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
+36
@@ -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<AppDramaCardVO> 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());
|
||||
}
|
||||
}
|
||||
+32
@@ -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<Wrapper<ContentComment>> 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<Long, Long> counts = commentService.countByDramas(
|
||||
Arrays.asList(7L, 8L, 8L, null));
|
||||
|
||||
assertThat(counts).containsEntry(7L, 5L).containsEntry(8L, 2L);
|
||||
Wrapper<ContentComment> wrapper = captor.getValue();
|
||||
assertThat(wrapper.getSqlSegment())
|
||||
.contains("drama_id")
|
||||
.contains("status")
|
||||
.contains("deleted")
|
||||
.contains("GROUP BY");
|
||||
AbstractWrapper<ContentComment, ?, ?> lambda = (AbstractWrapper<ContentComment, ?, ?>) wrapper;
|
||||
assertThat(lambda.getParamNameValuePairs().values())
|
||||
.contains(7L, 8L, "deleted");
|
||||
}
|
||||
}
|
||||
+2
@@ -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());
|
||||
|
||||
@@ -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")
|
||||
|
||||
+46
@@ -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<Long, Long> countByDramas(Collection<Long> dramaIds) {
|
||||
if (dramaIds == null || dramaIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> ids = dramaIds.stream().filter(Objects::nonNull).distinct().toList();
|
||||
if (ids.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
QueryWrapper<ContentComment> 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<Map<String, Object>> rows = commentMapper.selectMaps(wrapper);
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<Long, Long> counts = new HashMap<>();
|
||||
for (Map<String, Object> 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<CommentRespVO> adminPage(CommentQueryReqVO req) {
|
||||
|
||||
Reference in new issue
Block a user