From c6f729fb27460d524892b109944733641d44d2e8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 15:56:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(interaction):=20=E7=82=B9=E8=B5=9E?= =?UTF-8?q?=E4=B8=8E=E8=AF=84=E8=AE=BA=E6=8C=89=E9=9B=86=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 23 ++-- docs/plans/2026-10-21-K89-episode-like.md | 59 +++++++++ .../admin/controller/DramaController.java | 16 ++- .../admin/controller/EpisodeController.java | 17 ++- .../DramaControllerContractTest.java | 8 +- .../EpisodeControllerContractTest.java | 70 +++++++++++ .../app/controller/AppCommentController.java | 18 ++- .../app/controller/AppDramaController.java | 16 ++- .../app/service/AppCatalogService.java | 21 +++- .../com/fruitshorts/app/vo/AppEpisodeVO.java | 2 + .../AppCatalogControllerContractTest.java | 5 +- .../AppCommentControllerContractTest.java | 26 +++- .../AppCommentCountAnonymousSecurityTest.java | 3 +- .../controller/AppPlaybackContractTest.java | 19 ++- .../app/service/AppCatalogServiceTest.java | 35 ++++-- .../app/service/CommentServiceCountTest.java | 19 +++ .../app/service/EpisodeServiceK89Test.java | 113 ++++++++++++++++++ .../service/LikeServiceEpisodeTest.java | 112 +++++++++++++++++ .../content/dal/entity/DramaEpisode.java | 2 + .../content/service/EpisodeService.java | 69 +++++++++++ .../fruitshorts/content/vo/EpisodeRespVO.java | 8 ++ .../content/vo/EpisodeSaveReqVO.java | 3 + .../interaction/service/CommentService.java | 30 +++++ .../interaction/service/LikeService.java | 21 +++- .../interaction/vo/LikeToggleReqVO.java | 2 +- .../fruitshorts/playback/vo/PlayRespVO.java | 4 + sql/migrate-k89-episode-like.sql | 2 + sql/schema-phase1.sql | 1 + 28 files changed, 683 insertions(+), 41 deletions(-) create mode 100644 docs/plans/2026-10-21-K89-episode-like.md create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/EpisodeServiceK89Test.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java create mode 100644 sql/migrate-k89-episode-like.sql diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 1c4b138..031a19d 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -175,9 +175,10 @@ app.register.max-per-device-per-day > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 > -> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串), -> 点赞、收藏取 `drama` 冗余计数字段,评论数由 `content_comment` 按 `drama_id` 批量统计, -> 排除逻辑删除与 `status = 'deleted'` 的记录。首页、发现、分类、收藏、搜索等列表卡片结构一致。 +> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串)。 +> `favoriteCount` 取整部剧;`likeCount` / `commentCount` 取卡片 `preview` 当前集的 +> `drama_episode.like_count` 与 `content_comment.episode_id` 统计(排除逻辑删除与 `status = 'deleted'`); +> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。 > > 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。 @@ -218,6 +219,8 @@ app.register.max-per-device-per-day "isFree": true, "locked": false, "unlockCoin": 0, + "likeCount": "45", + "commentCount": "8", "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c..." } ] @@ -225,14 +228,16 @@ app.register.max-per-device-per-day } ``` -详情里的互动数量(K30 新增,供播放页右侧收藏/评论/点赞直接展示): +详情里的互动数量: | 字段 | 类型 | 说明 | | --- | --- | --- | -| `likeCount` | Long(JSON 字符串) | 取 `drama.like_count`,短剧累计点赞数 | +| `likeCount` | Long(JSON 字符串) | 剧级历史汇总,保留兼容;新客户端按集使用 `episodes[].likeCount` | | `favoriteCount` | Long(JSON 字符串) | 取 `drama.favorite_count`,短剧累计收藏数 | | `commentCount` | Long(JSON 字符串) | `content_comment` 中该剧未删除评论数:按 `drama_id` 统计,排除 `status = 'deleted'` 与逻辑删除记录 | | `viewCount` | Long(JSON 字符串) | 取 `drama.view_count`,累计播放量,既有字段语义不变 | +| `episodes[].likeCount` | Long(JSON 字符串) | 本集点赞数,取 `drama_episode.like_count` | +| `episodes[].commentCount` | Long(JSON 字符串) | 本集未删除评论数,按 `episode_id` 统计 | 计数是只读快照:互动操作完成后客户端重新拉取详情即可刷新;`Long` 字段一律按字符串下发, 客户端需要转成数字再展示(与 `viewCount` 相同)。 @@ -253,7 +258,9 @@ app.register.max-per-device-per-day "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...", "qualities": ["AUTO"], "unlockCoin": 0, - "previewSeconds": 0 + "previewSeconds": 0, + "likeCount": "45", + "commentCount": "8" } } ``` @@ -307,9 +314,9 @@ app.register.max-per-device-per-day | --- | --- | --- | --- | | POST | `/app/comments` | 登录 | 发表评论/回复:过敏感词后按策略发布或转待审 | | GET | `/app/comments?dramaId=&cursor=&size=` | 匿名 | 游标翻页;不传 `rootId` 返回顶层评论,传了返回该楼层回复 | -| GET | `/app/comments/count?dramaId=` | 匿名 | 返回该剧未删除评论总数,`Long` 按字符串下发 | +| GET | `/app/comments/count?dramaId=&episodeId=` | 匿名 | 传 `episodeId` 返回该集评论数;仅传 `dramaId` 返回全剧兼容汇总 | | DELETE | `/app/comments/{id}` | 登录 | 删除自己的评论(逻辑置为 `deleted`) | -| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `COMMENT` | +| POST | `/app/likes/toggle` | 登录 | 点赞/取消点赞,`targetType` 支持 `DRAMA` / `EPISODE` / `COMMENT`;新客户端点赞按集 | | POST | `/app/reports` | 登录 | 举报评论/用户/短剧;同一目标只保留一条待处理工单 | **敏感词三档处置**:`BLOCK` 直接拒绝并返回原因、`REPLACE` 替换后放行、`REVIEW` 转人工待审。 diff --git a/docs/plans/2026-10-21-K89-episode-like.md b/docs/plans/2026-10-21-K89-episode-like.md new file mode 100644 index 0000000..1dbbc71 --- /dev/null +++ b/docs/plans/2026-10-21-K89-episode-like.md @@ -0,0 +1,59 @@ +# K89 点赞/评论按集、收藏/热度按剧实施计划 + +## 目标 + +- 收藏、热度继续归属整部剧。 +- 点赞、评论归属具体剧集:新增 `drama_episode.like_count`,`content_like` 使用 `EPISODE` 目标。 +- App 卡片、详情、播放响应按当前集装配点赞/评论;后台短剧/剧集页提供对应汇总与只读展示。 + +## FruitServer A + +1. 数据库 + - 新增 `sql/migrate-k89-episode-like.sql`,只提交不执行。 + - `sql/schema-phase1.sql` 的 drama_episode 增量字段补 like_count,默认 0。 + +2. 分集点赞 + - `DramaEpisode` 增加 likeCount;`EpisodeService` 提供原子增减与批量 SUM。 + - `LikeService` 增加 `TYPE_EPISODE`,校验已上线剧集的已上线分集,切换时同步分集计数,返回该集最新值。 + - 保留 DRAMA/COMMENT 点赞兼容。 + +3. 分集评论 + - `CommentService.countByEpisodes` 按 episode_id 批量分组统计未删除评论。 + - `/app/comments/count` 增加可选 episodeId;传集数返回该集,只有 dramaId 时维持全剧兼容语义。 + +4. App 装配 + - 卡片 preview 集的 likeCount/commentCount 写入卡片;favoriteCount/viewCount 仍取剧。 + - 详情 episodes[] 每项增加 likeCount/commentCount。 + - 播放响应增加 episodeId 对应的 likeCount/commentCount。 + +5. 后台 + - EpisodeRespVO 增加本集 like/comment 与所属剧 favorite/heat;Controller.list 批量回填。 + - EpisodeSaveReqVO 增加可空 likeCount,update 仅覆盖非 null,负数 BAD_REQUEST。 + - DramaRespVO likeCount 改为分集 SUM,commentCount 保持全剧汇总;分页一次批量聚合避免 N+1。 + - 更新 API 契约。 + +6. 测试 + - EPISODE 点赞 +1/-1、不越界、目标不存在。 + - App 卡片按 preview 集、剧级 favorite/heat。 + - 后台分集列表计数与剧级收藏/热度。 + - 短剧分页 likeCount 为分集汇总。 + +## ReelShortWeb B + +1. EpisodeItem / EpisodeSaveData 增加对应互动字段。 +2. 剧集表格增加本集点赞、评论,以及所属剧收藏、热度列。 +3. 编辑弹窗增加本集点赞输入和评论只读;“按集实时统计”。 +4. 短剧管理保持收藏/热度可编辑,点赞/评论展示为后端只读汇总。 + +## 风险与处理 + +- 存量 `drama.like_count` 保留不删,不回填分集;新展示以分集 SUM 为准。 +- App 评论数按 preview 集统计,只有 episodeId 为空的历史评论仍计入全剧汇总。 +- 评论/点赞接口保持匿名/登录既有安全语义,不新增第三方依赖。 +- 迁移脚本由 PM 执行;本地门禁不访问真实数据库。 + +## 门禁与 Git + +- FruitServer:`mvn test` 全过;提交 `feat(interaction): 点赞与评论按集统计`;`--no-ff` 合回 develop 并 push。 +- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 剧集列表展示分集互动数据`;`--no-ff` 合回 develop 并 push。 +- 作者统一 `Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`;各自 PROGRESS 追加 K89 留痕。 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 1b66456..ce24fd3 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 @@ -6,6 +6,7 @@ import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.content.dal.entity.DramaReviewRecord; import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.vo.DramaPageReqVO; import com.fruitshorts.content.vo.DramaRespVO; import com.fruitshorts.content.vo.DramaSaveReqVO; @@ -38,13 +39,14 @@ public class DramaController { private final DramaService dramaService; private final CommentService commentService; + private final EpisodeService episodeService; @Operation(summary = "短剧分页查询") @GetMapping("/page") @PreAuthorize("@ss.hasPermission('drama:list')") public Result> page(DramaPageReqVO req) { PageResult page = dramaService.page(req); - fillCommentCounts(page.getList()); + fillInteractionCounts(page.getList()); return Result.ok(page); } @@ -53,7 +55,7 @@ public class DramaController { @PreAuthorize("@ss.hasPermission('drama:list')") public Result get(@PathVariable Long id) { DramaRespVO drama = dramaService.get(id); - fillCommentCounts(List.of(drama)); + fillInteractionCounts(List.of(drama)); return Result.ok(drama); } @@ -141,12 +143,16 @@ public class DramaController { SecurityUtils.getUserId(), SecurityUtils.getUsername()); } - private void fillCommentCounts(List dramas) { + private void fillInteractionCounts(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))); + Map likeCounts = episodeService.sumLikeCountsByDramas(ids); + Map commentCounts = commentService.countByDramas(ids); + dramas.forEach(drama -> { + drama.setLikeCount(likeCounts.getOrDefault(drama.getId(), 0L)); + drama.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L)); + }); } } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java index 2339740..4153b87 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java @@ -3,11 +3,14 @@ package com.fruitshorts.admin.controller; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.content.vo.EpisodeRespVO; import com.fruitshorts.content.vo.EpisodeSaveReqVO; import com.fruitshorts.content.vo.EpisodeSortReqVO; import com.fruitshorts.framework.log.OperationLog; +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; @@ -24,6 +27,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; @Tag(name = "后台-剧集管理") @RestController @@ -32,13 +36,24 @@ import java.util.List; public class EpisodeController { private final EpisodeService episodeService; + private final CommentService commentService; + private final DramaService dramaService; @Operation(summary = "剧集列表") @GetMapping @PreAuthorize("@ss.hasPermission('episode:list')") public Result> list(@RequestParam Long dramaId, @RequestParam(required = false) String status) { - return Result.ok(episodeService.listByDrama(dramaId, status)); + List episodes = episodeService.listByDrama(dramaId, status); + Map commentCounts = commentService.countByEpisodes( + episodes.stream().map(EpisodeRespVO::getId).toList()); + Drama drama = dramaService.getExisting(dramaId); + episodes.forEach(episode -> { + episode.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L)); + episode.setFavoriteCount(drama.getFavoriteCount()); + episode.setHeat(drama.getViewCount()); + }); + return Result.ok(episodes); } @Operation(summary = "新建剧集") 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 index 8f5fbb9..368998e 100644 --- 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 @@ -5,6 +5,7 @@ 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.service.EpisodeService; import com.fruitshorts.content.vo.DramaPageReqVO; import com.fruitshorts.content.vo.DramaRespVO; import com.fruitshorts.interaction.service.CommentService; @@ -28,14 +29,16 @@ class DramaControllerContractTest { private DramaService dramaService; private CommentService commentService; + private EpisodeService episodeService; private MockMvc mockMvc; @BeforeEach void setUp() { dramaService = mock(DramaService.class); commentService = mock(CommentService.class); + episodeService = mock(EpisodeService.class); mockMvc = ControllerContractSupport.mockMvcOf( - new DramaController(dramaService, commentService)); + new DramaController(dramaService, commentService, episodeService)); } @Test @@ -46,6 +49,8 @@ class DramaControllerContractTest { .willReturn(new PageResult<>(2L, List.of(first, second))); given(commentService.countByDramas(List.of(7L, 8L))) .willReturn(Map.of(7L, 11L)); + given(episodeService.sumLikeCountsByDramas(List.of(7L, 8L))) + .willReturn(Map.of(7L, 3200L, 8L, 120L)); mockMvc.perform(get("/admin/dramas/page")) .andExpect(status().isOk()) @@ -65,6 +70,7 @@ class DramaControllerContractTest { 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)); + given(episodeService.sumLikeCountsByDramas(List.of(7L))).willReturn(Map.of(7L, 3200L)); mockMvc.perform(get("/admin/dramas/7")) .andExpect(status().isOk()) diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java new file mode 100644 index 0000000..8b2dca7 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java @@ -0,0 +1,70 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.content.vo.EpisodeRespVO; +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.BDDMockito.given; +import static org.mockito.Mockito.mock; +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; + +/** K89:后台剧集列表按集回填点赞/评论,并带所属剧收藏/热度。 */ +class EpisodeControllerContractTest { + + private EpisodeService episodeService; + private CommentService commentService; + private DramaService dramaService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + episodeService = mock(EpisodeService.class); + commentService = mock(CommentService.class); + dramaService = mock(DramaService.class); + mockMvc = ControllerContractSupport.mockMvcOf( + new EpisodeController(episodeService, commentService, dramaService)); + } + + @Test + void should_return_episode_and_drama_interaction_counts() throws Exception { + given(episodeService.listByDrama(5L, null)).willReturn(List.of( + episode(11L, 10L), + episode(12L, 20L))); + given(commentService.countByEpisodes(List.of(11L, 12L))) + .willReturn(Map.of(11L, 3L)); + Drama drama = new Drama(); + drama.setFavoriteCount(88L); + drama.setViewCount(1000L); + given(dramaService.getExisting(5L)).willReturn(drama); + + mockMvc.perform(get("/admin/episodes").param("dramaId", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].likeCount").value("10")) + .andExpect(jsonPath("$.data[0].commentCount").value("3")) + .andExpect(jsonPath("$.data[0].favoriteCount").value("88")) + .andExpect(jsonPath("$.data[0].heat").value("1000")) + .andExpect(jsonPath("$.data[1].likeCount").value("20")) + .andExpect(jsonPath("$.data[1].commentCount").value("0")); + } + + private EpisodeRespVO episode(Long id, Long likeCount) { + EpisodeRespVO episode = new EpisodeRespVO(); + episode.setId(id); + episode.setDramaId(5L); + episode.setLikeCount(likeCount); + return episode; + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java index db44776..284ea12 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCommentController.java @@ -3,6 +3,8 @@ package com.fruitshorts.app.controller; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.web.WebUtils; @@ -24,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * 评论。 * @@ -38,6 +42,7 @@ import org.springframework.web.bind.annotation.RestController; public class AppCommentController { private final CommentService commentService; + private final EpisodeService episodeService; @Operation(summary = "发布评论") @PostMapping @@ -63,9 +68,18 @@ public class AppCommentController { return Result.ok(commentService.page(SecurityUtils.getUserIdOrNull(), query)); } - @Operation(summary = "短剧评论总数(可匿名访问)") + @Operation(summary = "评论总数:传 episodeId 按集统计,仅 dramaId 时全剧统计(可匿名访问)") @GetMapping("/count") - public Result count(@RequestParam Long dramaId) { + public Result count(@RequestParam Long dramaId, + @RequestParam(required = false) Long episodeId) { + if (episodeId != null) { + DramaEpisode episode = episodeService.getPublished(episodeId); + if (!dramaId.equals(episode.getDramaId())) { + return Result.ok(0L); + } + return Result.ok(commentService.countByEpisodes(List.of(episodeId)) + .getOrDefault(episodeId, 0L)); + } return Result.ok(commentService.countByDrama(dramaId)); } 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 312caba..86e2bd3 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 @@ -5,14 +5,20 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.framework.web.WebUtils; +import com.fruitshorts.interaction.service.CommentService; import com.fruitshorts.playback.service.PlaybackService; import com.fruitshorts.playback.vo.PlayRespVO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; + +import java.util.List; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -26,6 +32,8 @@ public class AppDramaController { private final AppCatalogService appCatalogService; private final PlaybackService playbackService; + private final EpisodeService episodeService; + private final CommentService commentService; /** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */ @Operation(summary = "短剧详情(含剧集列表,进详情页一次请求即可渲染)") @@ -41,10 +49,14 @@ public class AppDramaController { @Operation(summary = "播放票据:判定权益后签发短时效播放地址") @GetMapping("/episodes/{id}/play") public Result play(@PathVariable Long id, HttpServletRequest request) { - return Result.ok(playbackService.play( + PlayRespVO vo = playbackService.play( SecurityUtils.getUserIdOrNull(), id, request.getHeader("X-Device-Id"), - WebUtils.getClientIp(request))); + WebUtils.getClientIp(request)); + DramaEpisode episode = episodeService.getExisting(id); + vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount()); + vo.setCommentCount(commentService.countByEpisodes(List.of(id)).getOrDefault(id, 0L)); + return Result.ok(vo); } } 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 c6321e1..0d82b01 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 @@ -106,6 +106,8 @@ public class AppCatalogService { public AppDramaDetailVO dramaDetail(Long dramaId) { Drama drama = dramaService.getPublished(dramaId); List episodes = episodeService.listPublishedByDrama(dramaId); + Map episodeCommentCounts = commentService.countByEpisodes( + episodes.stream().map(DramaEpisode::getId).toList()); String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), ""); AppDramaDetailVO detail = new AppDramaDetailVO(); @@ -123,7 +125,9 @@ public class AppCatalogService { detail.setLikeCount(drama.getLikeCount()); detail.setFavoriteCount(drama.getFavoriteCount()); detail.setCommentCount(commentService.countByDrama(dramaId)); - detail.setEpisodes(episodes.stream().map(this::toEpisode).toList()); + detail.setEpisodes(episodes.stream() + .map(episode -> toEpisode(episode, episodeCommentCounts)) + .toList()); return detail; } @@ -168,7 +172,8 @@ public class AppCatalogService { private List cards(List dramas, Map categoryNames) { List ids = dramas.stream().map(Drama::getId).toList(); Map previews = episodeService.firstPlayableByDramas(ids); - Map commentCounts = commentService.countByDramas(ids); + Map commentCounts = commentService.countByEpisodes( + previews.values().stream().map(DramaEpisode::getId).toList()); return dramas.stream() .map(drama -> toCard( drama, categoryNames, previews.get(drama.getId()), commentCounts)) @@ -187,16 +192,18 @@ public class AppCatalogService { card.setTotalEpisodes(drama.getTotalEpisodes()); card.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1); card.setViewCount(drama.getViewCount()); - card.setLikeCount(drama.getLikeCount()); + card.setLikeCount(preview == null || preview.getLikeCount() == null + ? 0L : preview.getLikeCount()); card.setFavoriteCount(drama.getFavoriteCount()); - card.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L)); + card.setCommentCount(preview == null + ? 0L : commentCounts.getOrDefault(preview.getId(), 0L)); card.setLanguage(drama.getLanguage()); card.setDramaType(drama.getDramaType()); - card.setPreview(preview == null ? null : toEpisode(preview)); + card.setPreview(preview == null ? null : toEpisode(preview, commentCounts)); return card; } - private AppEpisodeVO toEpisode(DramaEpisode episode) { + private AppEpisodeVO toEpisode(DramaEpisode episode, Map commentCounts) { AppEpisodeVO vo = new AppEpisodeVO(); vo.setId(episode.getId()); vo.setNumber(episode.getEpNo()); @@ -208,6 +215,8 @@ public class AppCatalogService { vo.setIsFree(free); vo.setLocked(!free); vo.setUnlockCoin(episode.getUnlockCoin()); + vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount()); + vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L)); if (free) { // 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址 vo.setPlayUrl(playbackSigner.sign(playableSource(episode))); diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java index 0deb328..757b024 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java @@ -17,6 +17,8 @@ public class AppEpisodeVO { /** true 表示需要解锁后才能播放 */ private Boolean locked; private Integer unlockCoin; + private Long likeCount; + private Long commentCount; /** * 可播放时的地址;锁定或不含片源时为空。 * 详情页拿到就能直接播,不必按集再请求一次播放票据。 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 e5e2334..f27b4f9 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 @@ -10,9 +10,11 @@ import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppFeedVO; import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; import com.fruitshorts.playback.service.PlaybackService; +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; @@ -43,7 +45,8 @@ class AppCatalogControllerContractTest { catalogService = mock(AppCatalogService.class); catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService)); dramaMvc = ControllerContractSupport.mockMvcOf( - new AppDramaController(catalogService, mock(PlaybackService.class))); + new AppDramaController(catalogService, mock(PlaybackService.class), + mock(EpisodeService.class), mock(CommentService.class))); } @Test diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java index b091433..f96319c 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentControllerContractTest.java @@ -3,11 +3,16 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.support.ControllerContractSupport; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.EpisodeService; 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.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -19,13 +24,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class AppCommentControllerContractTest { private CommentService commentService; + private EpisodeService episodeService; private MockMvc mockMvc; @BeforeEach void setUp() { commentService = mock(CommentService.class); + episodeService = mock(EpisodeService.class); mockMvc = ControllerContractSupport.mockMvcOf( - new AppCommentController(commentService)); + new AppCommentController(commentService, episodeService)); } @Test @@ -40,4 +47,21 @@ class AppCommentControllerContractTest { verify(commentService).countByDrama(7L); } + + @Test + void should_count_comments_by_episode_when_episode_id_is_given() throws Exception { + DramaEpisode episode = new DramaEpisode(); + episode.setId(11L); + episode.setDramaId(7L); + given(episodeService.getPublished(11L)).willReturn(episode); + given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 4L)); + + mockMvc.perform(get("/app/comments/count") + .param("dramaId", "7") + .param("episodeId", "11")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("4")); + + verify(commentService).countByEpisodes(List.of(11L)); + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java index ba5726d..b765ade 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java @@ -5,6 +5,7 @@ package com.fruitshorts.app.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.framework.log.AccessDeniedRecorder; import com.fruitshorts.framework.security.JwtAuthenticationFilter; import com.fruitshorts.framework.security.JwtTokenProvider; @@ -90,7 +91,7 @@ class AppCommentCountAnonymousSecurityTest { @Bean AppCommentController appCommentController(CommentService commentService) { - return new AppCommentController(commentService); + return new AppCommentController(commentService, mock(EpisodeService.class)); } @Bean 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 aa7b0ef..9907a4d 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 @@ -3,6 +3,9 @@ package com.fruitshorts.app.controller; import com.fruitshorts.app.service.AppCatalogService; import com.fruitshorts.app.support.ControllerContractSupport; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.interaction.service.CommentService; import com.fruitshorts.playback.service.PlaybackService; import com.fruitshorts.playback.vo.PlayRespVO; import org.junit.jupiter.api.BeforeEach; @@ -10,6 +13,7 @@ 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.ArgumentMatchers.eq; @@ -30,13 +34,22 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. class AppPlaybackContractTest { private PlaybackService playbackService; + private EpisodeService episodeService; + private CommentService commentService; private MockMvc mockMvc; @BeforeEach void setUp() { playbackService = mock(PlaybackService.class); + episodeService = mock(EpisodeService.class); + commentService = mock(CommentService.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)); + new AppDramaController(mock(AppCatalogService.class), playbackService, + episodeService, commentService)); } @Test @@ -52,7 +65,9 @@ class AppPlaybackContractTest { .andExpect(jsonPath("$.data.episodeId").isString()) .andExpect(jsonPath("$.data.accessible").value(true)) .andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE)) - .andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1")); + .andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1")) + .andExpect(jsonPath("$.data.likeCount").value("88")) + .andExpect(jsonPath("$.data.commentCount").value("0")); } @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 a37c02d..1fde422 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 @@ -5,6 +5,7 @@ import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.common.pojo.CursorResult; import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.DramaCategoryService; import com.fruitshorts.content.service.DramaService; @@ -55,10 +56,12 @@ class AppCatalogServiceTest { drama.setLikeCount(3200L); drama.setFavoriteCount(1800L); drama.setViewCount(12400L); + DramaEpisode episode = episode(11L, 42L, 55L); given(dramaService.getPublished(42L)).willReturn(drama); - given(episodeService.listPublishedByDrama(42L)).willReturn(List.of()); + given(episodeService.listPublishedByDrama(42L)).willReturn(List.of(episode)); given(dramaCategoryService.listBasic()).willReturn(List.of()); given(commentService.countByDrama(42L)).willReturn(96L); + given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 2L)); AppDramaDetailVO detail = catalogService.dramaDetail(42L); @@ -66,6 +69,8 @@ class AppCatalogServiceTest { assertThat(detail.getLikeCount()).isEqualTo(3200L); assertThat(detail.getFavoriteCount()).isEqualTo(1800L); assertThat(detail.getCommentCount()).isEqualTo(96L); + assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(55L); + assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(2L); verify(commentService).countByDrama(42L); } @@ -83,10 +88,13 @@ class AppCatalogServiceTest { second.setViewCount(800L); second.setLikeCount(120L); second.setFavoriteCount(80L); - given(episodeService.firstPlayableByDramas(List.of(1L, 2L))).willReturn(Map.of()); + DramaEpisode firstEpisode = episode(11L, 1L, 320L); + DramaEpisode secondEpisode = episode(22L, 2L, 120L); + given(episodeService.firstPlayableByDramas(List.of(1L, 2L))) + .willReturn(Map.of(1L, firstEpisode, 2L, secondEpisode)); given(dramaCategoryService.listBasic()).willReturn(List.of()); - given(commentService.countByDramas(List.of(1L, 2L))) - .willReturn(Map.of(1L, 9L)); + given(commentService.countByEpisodes(List.of(11L, 22L))) + .willReturn(Map.of(11L, 9L)); List cards = catalogService.toCards(List.of(first, second)); @@ -97,7 +105,7 @@ class AppCatalogServiceTest { 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).countByEpisodes(List.of(11L, 22L)); verify(commentService, never()).countByDrama(any()); } @@ -107,14 +115,17 @@ class AppCatalogServiceTest { given(dramaService.listPublishedFeed(List.of("ANIME"), null, 21)) .willReturn(List.of(anime)); given(dramaCategoryService.listBasic()).willReturn(List.of()); - given(episodeService.firstPlayableByDramas(List.of(1L))).willReturn(Map.of()); - given(commentService.countByDramas(List.of(1L))).willReturn(Map.of(1L, 3L)); + DramaEpisode preview = episode(11L, 1L, 45L); + given(episodeService.firstPlayableByDramas(List.of(1L))) + .willReturn(Map.of(1L, preview)); + given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 3L)); CursorResult result = catalogService.feed("ANIME", null, null); assertThat(result.getList()).hasSize(1); assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME"); assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L); + assertThat(result.getList().get(0).getLikeCount()).isEqualTo(45L); assertThat(result.isHasMore()).isFalse(); verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21); } @@ -140,7 +151,7 @@ class AppCatalogServiceTest { .willReturn(List.of(second)); given(dramaCategoryService.listBasic()).willReturn(List.of()); given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of()); - given(commentService.countByDramas(any())).willReturn(Map.of()); + given(commentService.countByEpisodes(any())).willReturn(Map.of()); CursorResult firstPage = catalogService.feed(null, null, 1); CursorResult secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1); @@ -160,4 +171,12 @@ class AppCatalogServiceTest { drama.setDramaType(dramaType); return drama; } + + private DramaEpisode episode(Long id, Long dramaId, Long likeCount) { + DramaEpisode episode = new DramaEpisode(); + episode.setId(id); + episode.setDramaId(dramaId); + episode.setLikeCount(likeCount); + return episode; + } } 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 c99717f..53a4b18 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 @@ -109,4 +109,23 @@ class CommentServiceCountTest { assertThat(lambda.getParamNameValuePairs().values()) .contains(7L, 8L, "deleted"); } + + @Test + void should_group_comment_counts_by_episode() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + given(commentMapper.selectMaps(captor.capture())).willReturn(List.of( + Map.of("episode_id", 11L, "comment_count", 4L), + Map.of("episode_id", 12L, "comment_count", 2L))); + + Map counts = commentService.countByEpisodes( + Arrays.asList(11L, 12L, 12L, null)); + + assertThat(counts).containsEntry(11L, 4L).containsEntry(12L, 2L); + Wrapper wrapper = captor.getValue(); + assertThat(wrapper.getSqlSegment()) + .contains("episode_id") + .contains("status") + .contains("deleted") + .contains("GROUP BY"); + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/EpisodeServiceK89Test.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/EpisodeServiceK89Test.java new file mode 100644 index 0000000..0d6c265 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/EpisodeServiceK89Test.java @@ -0,0 +1,113 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.content.vo.EpisodeSaveReqVO; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** K89:剧集点赞编辑、原子增减与剧级批量汇总。 */ +class EpisodeServiceK89Test { + + private DramaEpisodeMapper episodeMapper; + private EpisodeService episodeService; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), DramaEpisode.class); + } + + @BeforeEach + void setUp() { + episodeMapper = mock(DramaEpisodeMapper.class); + episodeService = new EpisodeService(episodeMapper, mock(DramaService.class)); + } + + @Test + @SuppressWarnings("unchecked") + void should_use_greatest_zero_when_episode_like_decrements() { + given(episodeMapper.update(isNull(), any())).willReturn(1); + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + + episodeService.adjustLikeCount(11L, -1); + + verify(episodeMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper update = + (LambdaUpdateWrapper) captor.getValue(); + assertThat(update.getSqlSet()).contains("GREATEST(like_count - 1, 0)"); + } + + @Test + void should_update_episode_like_count_without_overwriting_when_null() { + DramaEpisode episode = new DramaEpisode(); + episode.setId(11L); + episode.setDramaId(5L); + episode.setEpNo(1); + episode.setLikeCount(8L); + given(episodeMapper.selectById(11L)).willReturn(episode); + EpisodeSaveReqVO req = new EpisodeSaveReqVO(); + req.setDramaId(5L); + req.setNumber(1); + req.setLikeCount(20L); + + episodeService.update(11L, req); + + assertThat(episode.getLikeCount()).isEqualTo(20L); + } + + @Test + void should_reject_negative_episode_like_count() { + DramaEpisode episode = new DramaEpisode(); + episode.setId(11L); + episode.setDramaId(5L); + episode.setEpNo(1); + given(episodeMapper.selectById(11L)).willReturn(episode); + EpisodeSaveReqVO req = new EpisodeSaveReqVO(); + req.setDramaId(5L); + req.setNumber(1); + req.setLikeCount(-1L); + + assertThatThrownBy(() -> episodeService.update(11L, req)) + .isInstanceOf(ServiceException.class) + .satisfies(error -> assertThat(((ServiceException) error).getCode()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode())); + verify(episodeMapper, never()).updateById(any(DramaEpisode.class)); + } + + @Test + void should_sum_episode_likes_by_drama() { + given(episodeMapper.selectMaps(any())).willReturn(List.of( + Map.of("drama_id", 5L, "like_count", 120L), + Map.of("drama_id", 6L, "like_count", 30L))); + + Map sums = episodeService.sumLikeCountsByDramas(List.of(5L, 6L)); + + assertThat(sums).containsEntry(5L, 120L).containsEntry(6L, 30L); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java new file mode 100644 index 0000000..10d5940 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/interaction/service/LikeServiceEpisodeTest.java @@ -0,0 +1,112 @@ +// Author: fruit.com.cn + +package com.fruitshorts.interaction.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +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.dal.entity.ContentLike; +import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; +import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper; +import com.fruitshorts.interaction.vo.LikeToggleReqVO; +import com.fruitshorts.interaction.vo.LikeToggleRespVO; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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; + +/** K89:点赞目标支持已上线的分集。 */ +class LikeServiceEpisodeTest { + + private ContentLikeMapper likeMapper; + private EpisodeService episodeService; + private DramaService dramaService; + private LikeService likeService; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), ContentLike.class); + } + + @BeforeEach + void setUp() { + likeMapper = mock(ContentLikeMapper.class); + episodeService = mock(EpisodeService.class); + dramaService = mock(DramaService.class); + likeService = new LikeService( + likeMapper, + mock(ContentCommentMapper.class), + dramaService, + episodeService); + } + + @Test + void should_increment_episode_like_and_return_latest_count() { + DramaEpisode published = episode(6L); + given(episodeService.getPublished(11L)).willReturn(published); + given(dramaService.getPublished(5L)).willReturn(new Drama()); + given(likeMapper.selectOne(any())).willReturn(null); + given(episodeService.getExisting(11L)).willReturn(published); + + LikeToggleRespVO result = likeService.toggle(9L, request(11L)); + + assertThat(result.getLiked()).isTrue(); + assertThat(result.getCount()).isEqualTo(6L); + verify(episodeService).adjustLikeCount(11L, 1); + } + + @Test + void should_decrement_episode_like_when_cancelled() { + DramaEpisode published = episode(5L); + given(episodeService.getPublished(11L)).willReturn(published); + given(dramaService.getPublished(5L)).willReturn(new Drama()); + given(likeMapper.selectOne(any())).willReturn(new ContentLike()); + given(episodeService.getExisting(11L)).willReturn(published); + + LikeToggleRespVO result = likeService.toggle(9L, request(11L)); + + assertThat(result.getLiked()).isFalse(); + assertThat(result.getCount()).isEqualTo(5L); + verify(likeMapper).physicalDelete(9L, LikeService.TYPE_EPISODE, 11L); + verify(episodeService).adjustLikeCount(11L, -1); + } + + @Test + void should_reject_missing_or_unpublished_episode() { + given(episodeService.getPublished(11L)) + .willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线")); + + assertThatThrownBy(() -> likeService.toggle(9L, request(11L))) + .isInstanceOf(ServiceException.class); + verify(likeMapper, never()).insert(any(ContentLike.class)); + } + + private DramaEpisode episode(Long likeCount) { + DramaEpisode episode = new DramaEpisode(); + episode.setId(11L); + episode.setDramaId(5L); + episode.setLikeCount(likeCount); + return episode; + } + + private LikeToggleReqVO request(Long episodeId) { + LikeToggleReqVO req = new LikeToggleReqVO(); + req.setTargetType(LikeService.TYPE_EPISODE); + req.setTargetId(episodeId); + return req; + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaEpisode.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaEpisode.java index bce5164..fa34e8c 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaEpisode.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaEpisode.java @@ -40,6 +40,8 @@ public class DramaEpisode { private String status; private LocalDateTime releaseAt; private Long playCount; + /** 本集点赞数(点赞目标为 EPISODE) */ + private Long likeCount; private Integer sort; private LocalDateTime createdAt; /** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */ 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 0c65ca8..c07c976 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 @@ -2,6 +2,7 @@ package com.fruitshorts.content.service; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; @@ -14,9 +15,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; @Service @RequiredArgsConstructor @@ -103,6 +107,7 @@ public class EpisodeService { apply(episode, req); episode.setStatus(req.getStatus() == null ? ContentStatus.DRAFT.getCode() : req.getStatus()); episode.setPlayCount(0L); + episode.setLikeCount(nonNegativeOrZero(req.getLikeCount())); episode.setVideoAssetId(0L); dramaEpisodeMapper.insert(episode); dramaService.syncTotalEpisodes(req.getDramaId()); @@ -115,6 +120,9 @@ public class EpisodeService { dramaService.getExisting(req.getDramaId()); ensureEpNoAvailable(req.getDramaId(), req.getNumber(), id); apply(episode, req); + if (req.getLikeCount() != null) { + episode.setLikeCount(nonNegative(req.getLikeCount())); + } if (req.getStatus() != null) { episode.setStatus(req.getStatus()); } @@ -157,6 +165,46 @@ public class EpisodeService { .set(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())); } + /** 本集点赞数原子增减,避免并发读改写丢计数。 */ + public void adjustLikeCount(Long episodeId, int delta) { + if (episodeId == null || delta == 0) { + return; + } + dramaEpisodeMapper.update(null, Wrappers.lambdaUpdate() + .eq(DramaEpisode::getId, episodeId) + .setSql(delta > 0 + ? "like_count = like_count + 1" + : "like_count = GREATEST(like_count - 1, 0)")); + } + + /** 批量汇总多部剧的分集点赞数,避免后台逐剧 N+1 查询。 */ + public Map sumLikeCountsByDramas(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 AS drama_id", "COALESCE(SUM(like_count), 0) AS like_count") + .in("drama_id", ids) + .eq("deleted", 0) + .groupBy("drama_id"); + List> rows = dramaEpisodeMapper.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")); + if (dramaId != null) { + counts.put(dramaId, asLong(row.get("like_count"), 0L)); + } + } + return counts; + } + private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) { Long count = dramaEpisodeMapper.selectCount(Wrappers.lambdaQuery() .eq(DramaEpisode::getDramaId, dramaId) @@ -194,10 +242,31 @@ public class EpisodeService { vo.setStatus(episode.getStatus()); vo.setStatusText(ContentStatus.textOf(episode.getStatus())); vo.setViews(episode.getPlayCount()); + vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount()); vo.setSort(episode.getSort()); return vo; } + private Long nonNegativeOrZero(Long value) { + return value == null ? 0L : nonNegative(value); + } + + private Long nonNegative(Long value) { + if (value < 0) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "本集点赞数不能小于 0"); + } + return value; + } + + 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; + } + private String formatDuration(Integer seconds) { int value = seconds == null ? 0 : seconds; return String.format("%02d:%02d", value / 60, value % 60); diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeRespVO.java index 3ba15c2..d1f3767 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeRespVO.java @@ -21,5 +21,13 @@ public class EpisodeRespVO { private String status; private String statusText; private Long views; + /** 本集点赞数 */ + private Long likeCount; + /** 本集评论数 */ + private Long commentCount; + /** 所属剧收藏数,只读 */ + private Long favoriteCount; + /** 所属剧热度(drama.view_count),只读 */ + private Long heat; private Integer sort; } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeSaveReqVO.java index def045c..ee61a0c 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeSaveReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/EpisodeSaveReqVO.java @@ -3,6 +3,7 @@ package com.fruitshorts.content.vo; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; import lombok.Data; @Data @@ -20,6 +21,8 @@ public class EpisodeSaveReqVO { private String videoUrl; private Boolean isFree; private Integer unlockCoin; + @PositiveOrZero(message = "本集点赞数不能小于 0") + private Long likeCount; private String status; private Integer sort; } 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 1459df9..47c2afd 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 @@ -52,6 +52,7 @@ import java.util.Set; public class CommentService { private static final String DRAMA_ID_COLUMN = "drama_id"; + private static final String EPISODE_ID_COLUMN = "episode_id"; private static final String COMMENT_COUNT_COLUMN = "comment_count"; public static final String STATUS_PENDING = "pending"; @@ -222,6 +223,35 @@ public class CommentService { return counts; } + /** 批量统计多集未删除评论数,按 episode_id 分组。 */ + public Map countByEpisodes(Collection episodeIds) { + if (episodeIds == null || episodeIds.isEmpty()) { + return Map.of(); + } + List ids = episodeIds.stream().filter(Objects::nonNull).distinct().toList(); + if (ids.isEmpty()) { + return Map.of(); + } + QueryWrapper wrapper = Wrappers.query(); + wrapper.select(EPISODE_ID_COLUMN, "COUNT(*) AS " + COMMENT_COUNT_COLUMN) + .in(EPISODE_ID_COLUMN, ids) + .eq("deleted", 0) + .ne("status", STATUS_DELETED) + .groupBy(EPISODE_ID_COLUMN); + List> rows = commentMapper.selectMaps(wrapper); + if (rows == null || rows.isEmpty()) { + return Map.of(); + } + Map counts = new HashMap<>(); + for (Map row : rows) { + Long episodeId = asLong(row.get(EPISODE_ID_COLUMN)); + if (episodeId != null) { + counts.put(episodeId, asLong(row.get(COMMENT_COUNT_COLUMN), 0L)); + } + } + return counts; + } + private Long asLong(Object value) { return value instanceof Number number ? number.longValue() : null; } 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 95f168f..a9f4fff 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 @@ -5,7 +5,9 @@ package com.fruitshorts.interaction.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.interaction.dal.entity.ContentComment; import com.fruitshorts.interaction.dal.entity.ContentLike; import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper; @@ -29,17 +31,19 @@ import java.util.Locale; public class LikeService { public static final String TYPE_DRAMA = "DRAMA"; + public static final String TYPE_EPISODE = "EPISODE"; public static final String TYPE_COMMENT = "COMMENT"; private final ContentLikeMapper likeMapper; private final ContentCommentMapper commentMapper; private final DramaService dramaService; + private final EpisodeService episodeService; @Transactional(rollbackFor = Exception.class) public LikeToggleRespVO toggle(Long userId, LikeToggleReqVO req) { String type = req.getTargetType().trim().toUpperCase(Locale.ROOT); - if (!TYPE_DRAMA.equals(type) && !TYPE_COMMENT.equals(type)) { - throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧或评论点赞"); + if (!TYPE_DRAMA.equals(type) && !TYPE_EPISODE.equals(type) && !TYPE_COMMENT.equals(type)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "只支持对短剧、剧集或评论点赞"); } Long targetId = req.getTargetId(); validateTarget(type, targetId); @@ -74,6 +78,10 @@ public class LikeService { return dramaService.getExisting(targetId).getLikeCount() == null ? 0L : dramaService.getExisting(targetId).getLikeCount(); } + if (TYPE_EPISODE.equals(type)) { + return episodeService.getExisting(targetId).getLikeCount() == null + ? 0L : episodeService.getExisting(targetId).getLikeCount(); + } ContentComment comment = commentMapper.selectById(targetId); return comment == null || comment.getLikeCount() == null ? 0L : comment.getLikeCount(); } @@ -83,6 +91,10 @@ public class LikeService { dramaService.adjustLikeCount(targetId, delta); return; } + if (TYPE_EPISODE.equals(type)) { + episodeService.adjustLikeCount(targetId, delta); + return; + } commentMapper.update(null, Wrappers.lambdaUpdate() .eq(ContentComment::getId, targetId) .setSql(delta > 0 @@ -95,6 +107,11 @@ public class LikeService { dramaService.getPublished(targetId); return; } + if (TYPE_EPISODE.equals(type)) { + DramaEpisode episode = episodeService.getPublished(targetId); + dramaService.getPublished(episode.getDramaId()); + return; + } ContentComment comment = commentMapper.selectById(targetId); if (comment == null || CommentService.STATUS_PUBLISHED.equals(comment.getStatus()) == false) { throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "评论不存在或未通过审核"); diff --git a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java index 5209a11..67e4db3 100644 --- a/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java +++ b/fruitshorts-module-interaction/src/main/java/com/fruitshorts/interaction/vo/LikeToggleReqVO.java @@ -10,7 +10,7 @@ import lombok.Data; @Data public class LikeToggleReqVO { - /** DRAMA/COMMENT */ + /** DRAMA/EPISODE/COMMENT */ @NotBlank(message = "缺少 targetType") private String targetType; diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java index fed5b25..efcb5b1 100644 --- a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java @@ -27,4 +27,8 @@ public class PlayRespVO { private Integer unlockCoin; /** 试看秒数,0 表示不限制 */ private Integer previewSeconds; + /** 本集点赞数 */ + private Long likeCount; + /** 本集评论数 */ + private Long commentCount; } diff --git a/sql/migrate-k89-episode-like.sql b/sql/migrate-k89-episode-like.sql new file mode 100644 index 0000000..d28c717 --- /dev/null +++ b/sql/migrate-k89-episode-like.sql @@ -0,0 +1,2 @@ +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS like_count BIGINT NOT NULL DEFAULT 0; +COMMENT ON COLUMN drama_episode.like_count IS '本集点赞数(点赞目标为 EPISODE)'; diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql index e52e8ab..ae10afe 100644 --- a/sql/schema-phase1.sql +++ b/sql/schema-phase1.sql @@ -44,6 +44,7 @@ ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS preview_seconds INT ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_url VARCHAR(512) NOT NULL DEFAULT ''; ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_lang VARCHAR(16) NOT NULL DEFAULT ''; ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS is_vip_only SMALLINT NOT NULL DEFAULT 0; -- 预留:会员专属(第二阶段) +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS like_count BIGINT NOT NULL DEFAULT 0; -- 本集点赞数 -- 媒资:CDN 地址与转码状态 ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS cdn_url VARCHAR(512) NOT NULL DEFAULT '';