diff --git a/docs/api-phase1.md b/docs/api-phase1.md index d0c509a..df701e4 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -174,6 +174,55 @@ app.register.max-per-device-per-day > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 +#### 短剧详情响应(`GET /app/dramas/{id}`) + +```json +{ + "code": 0, "message": "ok", + "data": { + "id": "5", + "title": "命运的相遇", + "synopsis": "一次意外的相遇,让两个世界的人走到一起。", + "cover": "/files/drama/202610/cover.jpg", + "genre": "都市 · 情感", + "category": "都市", + "totalEpisodes": 80, + "freeEpisodes": 10, + "unlockCoin": 30, + "isHot": true, + "viewCount": "12400", + "likeCount": "3200", + "favoriteCount": "1800", + "commentCount": "96", + "episodes": [ + { + "id": "2111012753262120962", + "number": 1, + "title": "第 1 集", + "duration": "03:00", + "durationSec": 180, + "isFree": true, + "locked": false, + "unlockCoin": 0, + "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..." + } + ] + } +} +``` + +详情里的互动数量(K30 新增,供播放页右侧收藏/评论/点赞直接展示): + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `likeCount` | Long(JSON 字符串) | 取 `drama.like_count`,短剧累计点赞数 | +| `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 | +| `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 | +| `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 | + +计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发, +客户端需要转成数字再展示(与 `viewCount` 相同)。 + ### 1.4 播放(已实现,链路已重做) | 方法 | 路径 | 鉴权 | 说明 | 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 e225812..21061e7 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 @@ -17,6 +17,7 @@ import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.DramaCategoryService; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.interaction.service.CommentService; import com.fruitshorts.media.service.PlaybackSigner; import com.fruitshorts.content.vo.DramaCategoryRespVO; import lombok.RequiredArgsConstructor; @@ -44,6 +45,7 @@ public class AppCatalogService { private final BannerService bannerService; private final DramaCategoryService dramaCategoryService; private final PlaybackSigner playbackSigner; + private final CommentService commentService; public AppFeedVO homeFeed() { Map categoryNames = categoryNameMap(); @@ -96,6 +98,9 @@ public class AppCatalogService { detail.setUnlockCoin(drama.getUnlockCoin()); detail.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1); detail.setViewCount(drama.getViewCount()); + detail.setLikeCount(drama.getLikeCount()); + detail.setFavoriteCount(drama.getFavoriteCount()); + detail.setCommentCount(commentService.countByDrama(dramaId)); detail.setEpisodes(episodes.stream().map(this::toEpisode).toList()); return detail; } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java index 799cf34..9992c04 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java @@ -20,5 +20,11 @@ public class AppDramaDetailVO { private Integer unlockCoin; 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; private List episodes; } 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 45430d0..4a2a034 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 @@ -89,6 +89,10 @@ class AppCatalogControllerContractTest { AppDramaDetailVO detail = new AppDramaDetailVO(); detail.setId(42L); detail.setTitle("命运的相遇"); + detail.setViewCount(12400L); + detail.setLikeCount(3200L); + detail.setFavoriteCount(1800L); + detail.setCommentCount(96L); detail.setEpisodes(List.of()); given(catalogService.dramaDetail(42L)).willReturn(detail); @@ -97,7 +101,11 @@ class AppCatalogControllerContractTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value("42")) .andExpect(jsonPath("$.data.id").isString()) - .andExpect(jsonPath("$.data.title").value("命运的相遇")); + .andExpect(jsonPath("$.data.title").value("命运的相遇")) + .andExpect(jsonPath("$.data.viewCount").value("12400")) + .andExpect(jsonPath("$.data.likeCount").value("3200")) + .andExpect(jsonPath("$.data.favoriteCount").value("1800")) + .andExpect(jsonPath("$.data.commentCount").value("96")); verify(catalogService).dramaDetail(42L); } 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 new file mode 100644 index 0000000..3cb3ab3 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -0,0 +1,66 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.service; + +import com.fruitshorts.app.vo.AppDramaDetailVO; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.service.BannerService; +import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.interaction.service.CommentService; +import com.fruitshorts.media.service.PlaybackSigner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * 目录聚合的互动数量装配:详情 VO 的点赞/收藏取短剧计数,评论数取互动域统计。 + */ +class AppCatalogServiceTest { + + private DramaService dramaService; + private EpisodeService episodeService; + private DramaCategoryService dramaCategoryService; + private CommentService commentService; + private AppCatalogService catalogService; + + @BeforeEach + void setUp() { + dramaService = mock(DramaService.class); + episodeService = mock(EpisodeService.class); + BannerService bannerService = mock(BannerService.class); + dramaCategoryService = mock(DramaCategoryService.class); + PlaybackSigner playbackSigner = mock(PlaybackSigner.class); + commentService = mock(CommentService.class); + catalogService = new AppCatalogService(dramaService, episodeService, bannerService, + dramaCategoryService, playbackSigner, commentService); + } + + @Test + void should_fill_interaction_counts_when_drama_detail_is_built() { + Drama drama = new Drama(); + drama.setId(42L); + drama.setTitle("命运的相遇"); + drama.setLikeCount(3200L); + drama.setFavoriteCount(1800L); + drama.setViewCount(12400L); + given(dramaService.getPublished(42L)).willReturn(drama); + given(episodeService.listPublishedByDrama(42L)).willReturn(List.of()); + given(dramaCategoryService.listBasic()).willReturn(List.of()); + given(commentService.countByDrama(42L)).willReturn(96L); + + AppDramaDetailVO detail = catalogService.dramaDetail(42L); + + assertThat(detail.getViewCount()).isEqualTo(12400L); + assertThat(detail.getLikeCount()).isEqualTo(3200L); + assertThat(detail.getFavoriteCount()).isEqualTo(1800L); + assertThat(detail.getCommentCount()).isEqualTo(96L); + verify(commentService).countByDrama(42L); + } +} 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 new file mode 100644 index 0000000..4de972b --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/CommentServiceCountTest.java @@ -0,0 +1,80 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.AbstractWrapper; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.interaction.config.InteractionProperties; +import com.fruitshorts.interaction.dal.entity.ContentComment; +import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper; +import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; +import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper; +import com.fruitshorts.interaction.service.AuditRecorder; +import com.fruitshorts.interaction.service.CommentService; +import com.fruitshorts.interaction.service.SensitiveWordService; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; + +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; + +/** + * 评论计数口径:按剧统计,排除已删除评论。 + */ +class CommentServiceCountTest { + + private ContentCommentMapper commentMapper; + private CommentService commentService; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), ContentComment.class); + } + + @BeforeEach + void setUp() { + commentMapper = mock(ContentCommentMapper.class); + commentService = new CommentService( + commentMapper, + mock(ContentLikeMapper.class), + mock(ContentAuditRecordMapper.class), + mock(AppUserMapper.class), + mock(DramaService.class), + mock(EpisodeService.class), + mock(SensitiveWordService.class), + mock(AuditRecorder.class), + mock(InteractionProperties.class)); + } + + @Test + void should_count_comments_of_drama_and_exclude_deleted() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + given(commentMapper.selectCount(captor.capture())).willReturn(5L); + + long count = commentService.countByDrama(7L); + + assertThat(count).isEqualTo(5L); + Wrapper wrapper = captor.getValue(); + assertThat(wrapper.getSqlSegment()).contains("drama_id").contains("status"); + AbstractWrapper lambda = (AbstractWrapper) wrapper; + assertThat(lambda.getParamNameValuePairs().values()).contains(7L, "deleted"); + } + + @Test + void should_return_zero_without_query_when_drama_id_is_missing() { + assertThat(commentService.countByDrama(null)).isZero(); + verify(commentMapper, never()).selectCount(any()); + } +} 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 156de55..77e0441 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 @@ -169,6 +169,22 @@ public class CommentService { softDelete(comment); } + /** + * 某部剧的评论总数(顶层评论与回复合计)。 + * + *

只统计未删除评论:逻辑删除的记录由 MyBatis-Plus 自动过滤, + * 用户删除后状态置为 deleted 的记录也要排除。 + */ + public long countByDrama(Long dramaId) { + if (dramaId == null) { + return 0L; + } + Long count = commentMapper.selectCount(Wrappers.lambdaQuery() + .eq(ContentComment::getDramaId, dramaId) + .ne(ContentComment::getStatus, STATUS_DELETED)); + return count == null ? 0L : count; + } + // ---------------- 后台 ---------------- public PageResult adminPage(CommentQueryReqVO req) {