From 2fe24b9db78c30d99c5f745baa2c9aaf982f6733 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 21:31:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(interaction):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E9=9B=86=E8=AE=A1=E6=95=B0=E7=BB=9F=E8=AE=A1=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 25 ++++++ .../app/controller/AppDramaController.java | 10 +++ .../app/service/AppEpisodeStatsService.java | 82 +++++++++++++++++++ .../fruitshorts/app/vo/AppEpisodeStatsVO.java | 18 ++++ .../AppCatalogControllerContractTest.java | 4 +- .../controller/AppPlaybackContractTest.java | 32 +++++++- .../service/AppEpisodeStatsServiceTest.java | 65 +++++++++++++++ .../content/service/EpisodeService.java | 14 ++++ .../interaction/service/LikeService.java | 26 ++++++ .../playback/service/FavoriteService.java | 25 ++++++ 10 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeStatsVO.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 8d68c0d..ac2114f 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -287,6 +287,7 @@ app.register.max-per-device-per-day | 方法 | 路径 | 鉴权 | 说明 | | --- | --- | --- | --- | | GET | `/app/episodes/{id}/play` | 可选 | 签发播放票据与签名地址 | +| GET | `/app/episodes/{id}/stats` | 可选 | 播放页轻量计数快照,不签发地址、不写票据缓存 | ```json { @@ -320,6 +321,30 @@ app.register.max-per-device-per-day `sprite_XXX.jpg#xywh=x,y,w,h`。有 READY `media_thumbnail` 时返回签名地址, 无产物时为 `null`;该字段不影响 `playUrl`、`qualities` 和 HLS/原文件回落。 +**`GET /app/episodes/{id}/stats`(播放页计数刷新)** + +只读返回本集点赞数、本集未删除评论数、所属剧收藏数,以及当前用户对 +本集/所属剧的互动状态。匿名请求时 `liked`、`favorited` 均为 `false`。 +接口不生成播放签名地址、不写 `playback_ticket` 缓存,计数变化后立即可读。 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "episodeId": "12", + "dramaId": "5", + "likeCount": "45", + "commentCount": "8", + "favoriteCount": "1800", + "liked": true, + "favorited": false + } +} +``` + +`likeCount`、`commentCount`、`favoriteCount` 与其他计数一样按 Long 字符串下发。 + ### 1.5 观看记录与收藏(已实现) | 方法 | 路径 | 说明 | diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java index 86e2bd3..79c5c1c 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java @@ -3,7 +3,9 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.service.AppEpisodeStatsService; import com.fruitshorts.app.vo.AppDramaDetailVO; +import com.fruitshorts.app.vo.AppEpisodeStatsVO; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.EpisodeService; @@ -34,6 +36,7 @@ public class AppDramaController { private final PlaybackService playbackService; private final EpisodeService episodeService; private final CommentService commentService; + private final AppEpisodeStatsService appEpisodeStatsService; /** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */ @Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)") @@ -59,4 +62,11 @@ public class AppDramaController { vo.setCommentCount(commentService.countByEpisodes(List.of(id)).getOrDefault(id, 0L)); return Result.ok(vo); } + + /** 轻量计数快照:只读互动状态,不签发播放地址、不写票据缓存。 */ + @Operation(summary = "播放页轻量计数与当前用户互动状态") + @GetMapping("/episodes/{id}/stats") + public Result stats(@PathVariable Long id) { + return Result.ok(appEpisodeStatsService.get(id, SecurityUtils.getUserIdOrNull())); + } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java new file mode 100644 index 0000000..877e29f --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppEpisodeStatsService.java @@ -0,0 +1,82 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.fruitshorts.app.vo.AppEpisodeStatsVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.interaction.service.CommentService; +import com.fruitshorts.interaction.service.LikeService; +import com.fruitshorts.playback.service.FavoriteService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** 播放页只读计数快照;固定维度批量查询,不签发播放地址或票据。 */ +@Service +@RequiredArgsConstructor +public class AppEpisodeStatsService { + + private final EpisodeService episodeService; + private final DramaService dramaService; + private final CommentService commentService; + private final LikeService likeService; + private final FavoriteService favoriteService; + + public AppEpisodeStatsVO get(Long episodeId, Long userId) { + Map stats = batch(List.of(episodeId), userId); + AppEpisodeStatsVO result = stats.get(episodeId); + if (result == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线"); + } + return result; + } + + /** 每类数据只查一次,供后续多集统计复用。 */ + public Map batch(Collection episodeIds, Long userId) { + if (episodeIds == null || episodeIds.isEmpty()) { + return Map.of(); + } + List episodes = episodeService.listPublishedByIds(episodeIds); + if (episodes.isEmpty()) { + return Map.of(); + } + List normalizedIds = episodes.stream().map(DramaEpisode::getId).toList(); + List dramaIds = episodes.stream().map(DramaEpisode::getDramaId).distinct().toList(); + Map commentCounts = commentService.countByEpisodes(normalizedIds); + Map dramas = new HashMap<>(); + for (Drama drama : dramaService.listPublishedByIds(dramaIds)) { + dramas.put(drama.getId(), drama); + } + Set likedIds = likeService.likedTargetIds( + userId, LikeService.TYPE_EPISODE, normalizedIds); + Set favoriteDramaIds = favoriteService.favoriteDramaIds(userId, dramaIds); + + Map result = new HashMap<>(); + for (DramaEpisode episode : episodes) { + Drama drama = dramas.get(episode.getDramaId()); + if (drama == null) { + continue; + } + AppEpisodeStatsVO vo = new AppEpisodeStatsVO(); + vo.setEpisodeId(episode.getId()); + vo.setDramaId(episode.getDramaId()); + vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount()); + vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L)); + vo.setFavoriteCount(drama.getFavoriteCount() == null ? 0L : drama.getFavoriteCount()); + vo.setLiked(likedIds.contains(episode.getId())); + vo.setFavorited(favoriteDramaIds.contains(episode.getDramaId())); + result.put(episode.getId(), vo); + } + return result; + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeStatsVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeStatsVO.java new file mode 100644 index 0000000..6b97278 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeStatsVO.java @@ -0,0 +1,18 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.vo; + +import lombok.Data; + +/** 播放页轻量计数快照,不触发票据签发。 */ +@Data +public class AppEpisodeStatsVO { + + private Long episodeId; + private Long dramaId; + private Long likeCount; + private Long commentCount; + private Long favoriteCount; + private Boolean liked; + private Boolean favorited; +} 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 b1793ee..fc754b2 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 @@ -2,6 +2,7 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.service.AppEpisodeStatsService; import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.app.vo.AppBannerVO; import com.fruitshorts.app.vo.AppCategoryVO; @@ -49,7 +50,8 @@ class AppCatalogControllerContractTest { catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService)); dramaMvc = ControllerContractSupport.mockMvcOf( new AppDramaController(catalogService, mock(PlaybackService.class), - mock(EpisodeService.class), mock(CommentService.class))); + mock(EpisodeService.class), mock(CommentService.class), + mock(AppEpisodeStatsService.class))); } @Test diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java index 7cc7c35..f1c0cb2 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java @@ -2,6 +2,8 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.service.AppEpisodeStatsService; +import com.fruitshorts.app.vo.AppEpisodeStatsVO; import com.fruitshorts.app.support.ControllerContractSupport; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.EpisodeService; @@ -37,6 +39,7 @@ class AppPlaybackContractTest { private PlaybackService playbackService; private EpisodeService episodeService; private CommentService commentService; + private AppEpisodeStatsService episodeStatsService; private MockMvc mockMvc; @BeforeEach @@ -44,13 +47,14 @@ class AppPlaybackContractTest { playbackService = mock(PlaybackService.class); episodeService = mock(EpisodeService.class); commentService = mock(CommentService.class); + episodeStatsService = mock(AppEpisodeStatsService.class); DramaEpisode episode = new DramaEpisode(); episode.setLikeCount(88L); given(episodeService.getExisting(any())).willReturn(episode); given(commentService.countByEpisodes(any())).willReturn(Map.of()); mockMvc = ControllerContractSupport.mockMvcOf( new AppDramaController(mock(AppCatalogService.class), playbackService, - episodeService, commentService)); + episodeService, commentService, episodeStatsService)); } @Test @@ -102,6 +106,32 @@ class AppPlaybackContractTest { verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any()); } + @Test + void should_return_episode_stats_without_play_ticket() throws Exception { + AppEpisodeStatsVO stats = new AppEpisodeStatsVO(); + stats.setEpisodeId(11L); + stats.setDramaId(7L); + stats.setLikeCount(88L); + stats.setCommentCount(8L); + stats.setFavoriteCount(1800L); + stats.setLiked(true); + stats.setFavorited(false); + given(episodeStatsService.get(11L, null)).willReturn(stats); + + mockMvc.perform(get("/app/episodes/11/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.episodeId").value("11")) + .andExpect(jsonPath("$.data.dramaId").value("7")) + .andExpect(jsonPath("$.data.likeCount").value("88")) + .andExpect(jsonPath("$.data.commentCount").value("8")) + .andExpect(jsonPath("$.data.favoriteCount").value("1800")) + .andExpect(jsonPath("$.data.liked").value(true)) + .andExpect(jsonPath("$.data.favorited").value(false)); + + verify(episodeStatsService).get(11L, null); + verify(playbackService, org.mockito.Mockito.never()).play(any(), any(), any(), any()); + } + private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl, String thumbnailsUrl) { PlayRespVO vo = new PlayRespVO(); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java new file mode 100644 index 0000000..665ed91 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppEpisodeStatsServiceTest.java @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.fruitshorts.app.vo.AppEpisodeStatsVO; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.interaction.service.CommentService; +import com.fruitshorts.interaction.service.LikeService; +import com.fruitshorts.playback.service.FavoriteService; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +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; + +class AppEpisodeStatsServiceTest { + + @Test + void should_batch_single_episode_stats_without_ticket_side_effects() { + EpisodeService episodeService = mock(EpisodeService.class); + DramaService dramaService = mock(DramaService.class); + CommentService commentService = mock(CommentService.class); + LikeService likeService = mock(LikeService.class); + FavoriteService favoriteService = mock(FavoriteService.class); + + DramaEpisode episode = new DramaEpisode(); + episode.setId(11L); + episode.setDramaId(7L); + episode.setLikeCount(88L); + Drama drama = new Drama(); + drama.setId(7L); + drama.setFavoriteCount(1800L); + given(episodeService.listPublishedByIds(List.of(11L))).willReturn(List.of(episode)); + given(dramaService.listPublishedByIds(List.of(7L))).willReturn(List.of(drama)); + given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 8L)); + given(likeService.likedTargetIds( + 100L, LikeService.TYPE_EPISODE, List.of(11L))).willReturn(Set.of(11L)); + given(favoriteService.favoriteDramaIds(100L, List.of(7L))).willReturn(Set.of()); + + AppEpisodeStatsService service = new AppEpisodeStatsService( + episodeService, dramaService, commentService, likeService, favoriteService); + AppEpisodeStatsVO result = service.get(11L, 100L); + + assertThat(result.getEpisodeId()).isEqualTo(11L); + assertThat(result.getDramaId()).isEqualTo(7L); + assertThat(result.getLikeCount()).isEqualTo(88L); + assertThat(result.getCommentCount()).isEqualTo(8L); + assertThat(result.getFavoriteCount()).isEqualTo(1800L); + assertThat(result.getLiked()).isTrue(); + assertThat(result.getFavorited()).isFalse(); + verify(episodeService).listPublishedByIds(List.of(11L)); + verify(commentService).countByEpisodes(List.of(11L)); + verify(likeService).likedTargetIds( + 100L, LikeService.TYPE_EPISODE, List.of(11L)); + verify(favoriteService).favoriteDramaIds(100L, List.of(7L)); + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java index fa5655f..25c1654 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java @@ -158,6 +158,20 @@ public class EpisodeService implements EpisodeTranscodeCompletionPort, EpisodeVi Wrappers.lambdaQuery().in(DramaEpisode::getId, ids)); } + /** C 端:按 ID 批量取已上线剧集,用于只读统计。 */ + public List listPublishedByIds(Collection ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + List normalized = ids.stream().filter(Objects::nonNull).distinct().toList(); + if (normalized.isEmpty()) { + return List.of(); + } + return dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() + .in(DramaEpisode::getId, normalized) + .eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())); + } + @Transactional(rollbackFor = Exception.class) public Long create(EpisodeSaveReqVO req) { dramaService.getExisting(req.getDramaId()); diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java index a9f4fff..7f19c8b 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/service/LikeService.java @@ -18,7 +18,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; /** * 点赞。关系表是事实来源,计数同步更新在目标行上。 @@ -86,6 +89,29 @@ public class LikeService { return comment == null || comment.getLikeCount() == null ? 0L : comment.getLikeCount(); } + /** 批量判断用户是否已点赞,一次查询覆盖多个目标。 */ + public Set likedTargetIds(Long userId, String type, Collection targetIds) { + if (userId == null || targetIds == null || targetIds.isEmpty()) { + return Set.of(); + } + Set normalized = new HashSet<>(); + for (Long targetId : targetIds) { + if (targetId != null) { + normalized.add(targetId); + } + } + if (normalized.isEmpty()) { + return Set.of(); + } + return likeMapper.selectList(Wrappers.lambdaQuery() + .eq(ContentLike::getUserId, userId) + .eq(ContentLike::getTargetType, type) + .in(ContentLike::getTargetId, normalized)) + .stream() + .map(ContentLike::getTargetId) + .collect(java.util.stream.Collectors.toSet()); + } + private void adjustCount(String type, Long targetId, int delta) { if (TYPE_DRAMA.equals(type)) { dramaService.adjustLikeCount(targetId, delta); diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java index 0c3f1a9..9fde4d2 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java @@ -17,9 +17,12 @@ import org.springframework.transaction.annotation.Transactional; 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.Set; /** 收藏:切换、列表、批量删除;收藏数在同一事务内同步维护。 */ @Service @@ -63,6 +66,28 @@ public class FavoriteService { return count != null && count > 0; } + /** 批量判断用户是否已收藏,一次查询覆盖多部剧。 */ + public Set favoriteDramaIds(Long userId, Collection dramaIds) { + if (userId == null || dramaIds == null || dramaIds.isEmpty()) { + return Set.of(); + } + Set normalized = new HashSet<>(); + for (Long dramaId : dramaIds) { + if (dramaId != null) { + normalized.add(dramaId); + } + } + if (normalized.isEmpty()) { + return Set.of(); + } + return favoriteMapper.selectList(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .in(UserFavorite::getDramaId, normalized)) + .stream() + .map(UserFavorite::getDramaId) + .collect(java.util.stream.Collectors.toSet()); + } + /** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */ public PageResult page(Long userId, int pageIndex, int pageSize) { int safeIndex = Math.max(pageIndex, 1);