feat(interaction): 新增分集计数统计接口

This commit is contained in:
Zhou Weidong committed 2026-10-26 21:31:17 +08:00
1 parent 15b540cdd6
commit 2fe24b9db7
10 files changed
+299 -2

No files matched your search

+25
View File
@@ -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 观看记录与收藏(已实现)
| 方法 | 路径 | 说明 |
@@ -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<AppEpisodeStatsVO> stats(@PathVariable Long id) {
return Result.ok(appEpisodeStatsService.get(id, SecurityUtils.getUserIdOrNull()));
}
}
@@ -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<Long, AppEpisodeStatsVO> 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<Long, AppEpisodeStatsVO> batch(Collection<Long> episodeIds, Long userId) {
if (episodeIds == null || episodeIds.isEmpty()) {
return Map.of();
}
List<DramaEpisode> episodes = episodeService.listPublishedByIds(episodeIds);
if (episodes.isEmpty()) {
return Map.of();
}
List<Long> normalizedIds = episodes.stream().map(DramaEpisode::getId).toList();
List<Long> dramaIds = episodes.stream().map(DramaEpisode::getDramaId).distinct().toList();
Map<Long, Long> commentCounts = commentService.countByEpisodes(normalizedIds);
Map<Long, Drama> dramas = new HashMap<>();
for (Drama drama : dramaService.listPublishedByIds(dramaIds)) {
dramas.put(drama.getId(), drama);
}
Set<Long> likedIds = likeService.likedTargetIds(
userId, LikeService.TYPE_EPISODE, normalizedIds);
Set<Long> favoriteDramaIds = favoriteService.favoriteDramaIds(userId, dramaIds);
Map<Long, AppEpisodeStatsVO> 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;
}
}
@@ -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;
}
@@ -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
@@ -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();
@@ -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));
}
}
@@ -158,6 +158,20 @@ public class EpisodeService implements EpisodeTranscodeCompletionPort, EpisodeVi
Wrappers.<DramaEpisode>lambdaQuery().in(DramaEpisode::getId, ids));
}
/** C 端:按 ID 批量取已上线剧集,用于只读统计。 */
public List<DramaEpisode> listPublishedByIds(Collection<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
List<Long> normalized = ids.stream().filter(Objects::nonNull).distinct().toList();
if (normalized.isEmpty()) {
return List.of();
}
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.in(DramaEpisode::getId, normalized)
.eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode()));
}
@Transactional(rollbackFor = Exception.class)
public Long create(EpisodeSaveReqVO req) {
dramaService.getExisting(req.getDramaId());
@@ -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<Long> likedTargetIds(Long userId, String type, Collection<Long> targetIds) {
if (userId == null || targetIds == null || targetIds.isEmpty()) {
return Set.of();
}
Set<Long> normalized = new HashSet<>();
for (Long targetId : targetIds) {
if (targetId != null) {
normalized.add(targetId);
}
}
if (normalized.isEmpty()) {
return Set.of();
}
return likeMapper.selectList(Wrappers.<ContentLike>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);
@@ -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<Long> favoriteDramaIds(Long userId, Collection<Long> dramaIds) {
if (userId == null || dramaIds == null || dramaIds.isEmpty()) {
return Set.of();
}
Set<Long> normalized = new HashSet<>();
for (Long dramaId : dramaIds) {
if (dramaId != null) {
normalized.add(dramaId);
}
}
if (normalized.isEmpty()) {
return Set.of();
}
return favoriteMapper.selectList(Wrappers.<UserFavorite>lambdaQuery()
.eq(UserFavorite::getUserId, userId)
.in(UserFavorite::getDramaId, normalized))
.stream()
.map(UserFavorite::getDramaId)
.collect(java.util.stream.Collectors.toSet());
}
/** 分页查询收藏短剧,保持收藏时间与 dramaId 稳定排序。 */
public PageResult<Drama> page(Long userId, int pageIndex, int pageSize) {
int safeIndex = Math.max(pageIndex, 1);